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>
This commit is contained in:
klopez4212
2026-10-22 15:33:49 -07:00
committed by GitHub
parent 20ba95ed89
commit dc25198d53
8 changed files with 187 additions and 22 deletions

View File

@@ -18,7 +18,15 @@ struct MeAgentAccessibilityTests {
onCancel: {}, onSet: { _ in }
)
controller.loadViewIfNeeded()
let cell = controller.tableView(controller.tableView, cellForRowAt: IndexPath(row: 0, section: 1))
precondition(controller.numberOfSections(in: controller.tableView) == 3)
let projectCell = controller.tableView(controller.tableView, cellForRowAt: IndexPath(row: 0, section: 1))
let project = projectCell.accessoryView as! UIButton
precondition(project.accessibilityLabel == "Project")
precondition(project.accessibilityValue == "No project")
precondition(project.titleColor(for: .normal) == .label)
precondition(project.isEnabled == !locked)
precondition(project.showsMenuAsPrimaryAction && project.menu != nil)
let cell = controller.tableView(controller.tableView, cellForRowAt: IndexPath(row: 0, section: 2))
let disabled = locked || empty
precondition(cell.accessibilityTraits.contains(.notEnabled) == disabled)
precondition(cell.accessibilityTraits.contains(.button) == !disabled)

View File

@@ -245,6 +245,10 @@ private final class NativeProfileTextEditorViewController:
private var hasFocusedEditor = false
private var selectedAssistantAvatar: UIImage?
private var selectedAssistant: String?
private var selectedProject: String?
private var projects: [[String: String]] {
chatOptions?["projects"] as? [[String: String]] ?? []
}
private var isChatEditor: Bool { chatOptions != nil }
private var isProjectEditor: Bool { chatOptions?["projectOnly"] as? Bool ?? false }
private var assistants: [[String: String]] {
@@ -333,6 +337,7 @@ private final class NativeProfileTextEditorViewController:
self.canvasLoaded = canvasLoaded
self.chatOptions = chatOptions
self.selectedAssistant = chatOptions?["selectedAssistant"] as? String
self.selectedProject = chatOptions?["selectedProject"] as? String
self.onCancel = onCancel
self.onSet = onSet
super.init(style: .insetGrouped)
@@ -472,7 +477,7 @@ private final class NativeProfileTextEditorViewController:
assistants.contains(where: { $0["id"] == selectedAssistant }),
!currentValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
currentValue.count <= 120 else { return }
onSet(["name": currentValue, "assistant": selectedAssistant])
onSet(["name": currentValue, "assistant": selectedAssistant, "project": selectedProject ?? ""])
} else if isChannelEditor {
guard !canonicalChannelName.isEmpty else { return }
onSet(["action": "save", "name": canonicalChannelName, "description": textView.text ?? ""])
@@ -503,13 +508,13 @@ private final class NativeProfileTextEditorViewController:
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
if isChatEditor && indexPath.section == 1 {
if isChatEditor && indexPath.section == 2 {
guard !fieldsLocked && !assistants.isEmpty else { return }
view.endEditing(true)
let picker = MeAssistantListController(assistants: assistants, selected: selectedAssistant, pageColor: pageBackgroundColor, rowColor: containerBackgroundColor) { [weak self] id, avatar in
self?.selectedAssistantAvatar = avatar
self?.selectedAssistant = id
self?.tableView.reloadSections(IndexSet(integer: 1), with: .none)
self?.tableView.reloadSections(IndexSet(integer: 2), with: .none)
self?.updateNavigation()
}
navigationController?.pushViewController(picker, animated: true)
@@ -529,10 +534,10 @@ private final class NativeProfileTextEditorViewController:
return false
}
override func numberOfSections(in tableView: UITableView) -> Int { isProjectEditor ? 1 : isChatEditor ? 2 : isChannelEditor ? (hasCanvas && canEditCanvas ? 4 : 3) : 1 }
override func numberOfSections(in tableView: UITableView) -> Int { isProjectEditor ? 1 : isChatEditor ? 3 : isChannelEditor ? (hasCanvas && canEditCanvas ? 4 : 3) : 1 }
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
guard isChatEditor && section == 1 else { return nil }
guard isChatEditor && section == 2 else { return nil }
if let error = chatOptions?["error"] as? String { return error }
return assistants.isEmpty
? "No agents are available for this account. Connect an agent on desktop, then try again."
@@ -548,7 +553,7 @@ private final class NativeProfileTextEditorViewController:
_ tableView: UITableView,
heightForRowAt indexPath: IndexPath
) -> CGFloat {
if isChatEditor && indexPath.section == 1 && selectedAssistant != nil { return 64 }
if isChatEditor && indexPath.section == 2 && selectedAssistant != nil { return 64 }
if isChannelEditor && indexPath.section >= 2 { return UITableView.automaticDimension }
return (isChannelEditor ? indexPath.section == 1 : multiline) ? 132 : 52
}
@@ -564,6 +569,36 @@ private final class NativeProfileTextEditorViewController:
cell.layer.cornerCurve = .continuous
cell.clipsToBounds = true
if isChatEditor && indexPath.section == 1 {
var content = cell.defaultContentConfiguration()
content.text = "Project"
content.textProperties.font = .preferredFont(forTextStyle: .body)
cell.contentConfiguration = content
let selected = projects.first { $0["id"] == selectedProject }
let name = selected?["name"] ?? (selectedProject == nil ? "No project" : "Unavailable project")
let button = UIButton(type: .system)
button.setTitle(name, for: .normal)
button.setTitleColor(.label, for: .normal)
button.setTitleColor(.secondaryLabel, for: .disabled)
button.titleLabel?.font = .preferredFont(forTextStyle: .body)
button.titleLabel?.adjustsFontForContentSizeCategory = true
button.accessibilityLabel = "Project"
button.accessibilityValue = name
button.isEnabled = !fieldsLocked
button.showsMenuAsPrimaryAction = true
let choices = [["id": "", "name": "No project"]] + projects
button.menu = UIMenu(children: choices.map { project in
let id = project["id"] ?? ""
return UIAction(title: project["name"] ?? "Project", state: id == (selectedProject ?? "") ? .on : .off) { [weak self] _ in
self?.view.endEditing(true)
self?.selectedProject = id.isEmpty ? nil : id
self?.tableView.reloadSections(IndexSet(integer: 1), with: .none)
}
})
button.titleLabel?.lineBreakMode = .byTruncatingTail
button.sizeToFit()
button.frame.size.width = min(button.frame.width, tableView.bounds.width * 0.55)
cell.accessoryView = button
} else if isChatEditor && indexPath.section == 2 {
var content = cell.defaultContentConfiguration()
let selected = assistants.first { $0["id"] == selectedAssistant }
content.text = selected?["name"] ?? "Choose an agent"

View File

@@ -43,6 +43,7 @@ Future<(Channel, String)?> _showNewMeChat(
final id = pending?.id ?? const Uuid().v4();
String name = pending?.name ?? '';
String? assistant = pending?.agent;
String? project = pending?.project;
String? error = pending == null ? null : 'Resume your unfinished chat.';
var retry = pending != null;
while (context.mounted) {
@@ -68,6 +69,12 @@ Future<(Channel, String)?> _showNewMeChat(
.toARGB32(),
'containerCornerRadius': Radii.container,
'selectedAssistant': assistant,
'selectedProject': project,
'projects': [
for (final group
in ref.read(meGroupsProvider).requireValue.groups)
{'id': group['id'], 'name': group['name']},
],
'fieldsLocked': retry,
'error': error,
'assistants': [
@@ -99,10 +106,19 @@ Future<(Channel, String)?> _showNewMeChat(
if (draft == null || !context.mounted) return null;
name = (draft['name'] as String? ?? '').trim();
assistant = draft['assistant'] as String?;
project = draft['project'] as String?;
if (project == '') project = null;
if (name.isEmpty || assistant == null) return null;
retry = true;
try {
final created = await _createMeChat(ref, sync, id, name, assistant);
final created = await _createMeChat(
ref,
sync,
id,
name,
assistant,
project,
);
return (created, assistant);
} catch (failure) {
if (failure is MeRecoveryConflict && context.mounted) {
@@ -129,9 +145,10 @@ Future<Channel> _createMeChat(
String channelId,
String name,
String assistant,
String? project,
) async {
sync = _currentMeSync(ref, sync);
final intent = MeChatCreation(channelId, name, assistant);
final intent = MeChatCreation(channelId, name, assistant, project: project);
await intent.save(sync);
final owned = await isOwnedMeAssistant(sync, assistant);
sync.check();
@@ -140,7 +157,7 @@ Future<Channel> _createMeChat(
}
final head = await sync.head();
(head == null ? const MeGroups() : sync.decode(head))
.place(channelId)
.place(channelId, group: project)
.encode(sync.community);
final actions = ref.read(channelActionsProvider);
return intent.complete<Channel>(
@@ -166,8 +183,12 @@ Future<Channel> _createMeChat(
actions.addMembers(channelId: channelId, pubkeys: [assistant]),
isPlaced: () async {
final latest = await sync.head();
return latest != null && sync.decode(latest).channels.contains(channelId);
if (latest == null) return false;
final groups = sync.decode(latest);
return groups.channels.contains(channelId) &&
(project == null || groups.assignments[channelId] == project);
},
place: () => sync.update((current) => current.place(channelId)),
place: () =>
sync.update((current) => current.place(channelId, group: project)),
);
}

View File

@@ -8,6 +8,8 @@ class _NewMeChat extends HookConsumerWidget {
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);
@@ -52,6 +54,34 @@ class _NewMeChat extends HookConsumerWidget {
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
@@ -130,6 +160,7 @@ class _NewMeChat extends HookConsumerWidget {
channelId,
frozenName.value!,
frozenAgent.value!,
project.value,
);
if (context.mounted) {
Navigator.of(

View File

@@ -1,4 +1,5 @@
import 'dart:convert';
import '../crypto/nip44.dart';
import 'me_sync.dart';
import 'me_journal.dart';
@@ -14,8 +15,11 @@ class MeChatCreation {
/// Selected agent public key.
final String agent;
/// Optional Solo project, frozen with the rest of the creation intent.
final String? project;
/// Create a new intent; persistence happens before [complete] performs writes.
const MeChatCreation(this.id, this.name, this.agent);
const MeChatCreation(this.id, this.name, this.agent, {this.project});
/// Account and community boundary for durable creation storage.
static String storageKey(MeSync sync) =>
@@ -31,6 +35,7 @@ class MeChatCreation {
value['id'] as String,
value['name'] as String,
value['agent'] as String,
project: value['project'] as String?,
);
}
@@ -41,7 +46,8 @@ class MeChatCreation {
if (previous != null &&
(previous.id != id ||
previous.name != name ||
previous.agent != agent)) {
previous.agent != agent ||
previous.project != project)) {
throw StateError('Resume the pending chat before starting another.');
}
if (sync.prefs == null ||
@@ -49,7 +55,12 @@ class MeChatCreation {
storageKey(sync),
nip44Encrypt(
sync.key,
jsonEncode({'id': id, 'name': name, 'agent': agent}),
jsonEncode({
'id': id,
'name': name,
'agent': agent,
'project': project,
}),
),
)) {
throw StateError('Couldn’t save this chat on your phone. Try again.');
@@ -62,7 +73,10 @@ class MeChatCreation {
Future<void> discard(MeSync sync) async {
if (sync.disposed) throw StateError('Your community changed. Try again.');
final current = pending(sync);
if (current?.id != id || current?.name != name || current?.agent != agent) {
if (current?.id != id ||
current?.name != name ||
current?.agent != agent ||
current?.project != project) {
throw StateError('The unfinished chat changed. Review it again.');
}
await clearMeJournal(

View File

@@ -65,7 +65,11 @@ void main() {
sync = MeSync(relay, config, prefs: prefs, connected: false);
container.invalidate(meSyncProvider);
}
return {'name': 'Saved chat', 'assistant': 'agent'};
return {
'name': 'Saved chat',
'assistant': 'agent',
'project': 'mobile',
};
});
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
@@ -106,6 +110,7 @@ void main() {
);
expect(saved!.name, 'Saved chat');
expect(saved.agent, 'agent');
expect(saved.project, 'mobile');
expect(
prefs.getString(MeChatCreation.storageKey(sync)),
isNot(contains('Saved chat')),

View File

@@ -201,7 +201,7 @@ void main() {
},
);
testWidgets(
'footer new chat action opens both fields in one dismissible sheet',
'footer new chat offers project selection without losing its name',
(tester) async {
final action = ValueNotifier<VoidCallback?>(null);
addTearDown(action.dispose);
@@ -220,7 +220,17 @@ void main() {
ProviderScope(
overrides: [
meGroupsProvider.overrideWith(
(ref) => Stream.value(const MeGroups()),
(ref) => Stream.value(
const MeGroups(
groups: [
{
'id': 'mobile',
'name': 'Mobile app',
'defaultTemplateId': '',
},
],
),
),
),
meSyncProvider.overrideWithValue(sync),
channelsProvider.overrideWith(FakeChannels.new),
@@ -250,6 +260,16 @@ void main() {
find.byKey(const ValueKey('new-session-name')),
'Design session',
);
await tester.tap(find.byKey(const ValueKey('new-session-project')));
await tester.pumpAndSettle();
await tester.tap(find.text('Mobile app').last);
await tester.pumpAndSettle();
expect(find.text('Design session'), findsOneWidget);
expect(find.text('Mobile app'), findsWidgets);
await tester.tap(find.byKey(const ValueKey('new-session-project')));
await tester.pumpAndSettle();
await tester.tap(find.text('No project').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Create chat'));
await tester.pumpAndSettle();
expect(find.text('Enter a name and choose an agent.'), findsOneWidget);
@@ -257,7 +277,7 @@ void main() {
},
);
testWidgets('iOS new chat uses the native profile form with both fields', (
testWidgets('iOS new chat uses the native profile form with project choices', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
@@ -292,7 +312,17 @@ void main() {
ProviderScope(
overrides: [
meGroupsProvider.overrideWith(
(ref) => Stream.value(const MeGroups()),
(ref) => Stream.value(
const MeGroups(
groups: [
{
'id': 'mobile',
'name': 'Mobile app',
'defaultTemplateId': '',
},
],
),
),
),
meSyncProvider.overrideWithValue(sync),
channelsProvider.overrideWith(FakeChannels.new),
@@ -318,6 +348,10 @@ void main() {
expect(args['title'], 'New chat');
expect(args['placeholder'], 'Chat name');
expect(args['assistants'], isEmpty);
expect(args['projects'], [
{'id': 'mobile', 'name': 'Mobile app'},
]);
expect(args['selectedProject'], isNull);
expect(args['containerCornerRadius'], Radii.container);
expect(find.byType(BottomSheet), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
@@ -363,6 +397,7 @@ void main() {
'same-id',
'Unfinished chat',
'agent',
project: 'mobile',
).save(sync);
final restarted = MeSync(
RelaySessionNotifier(),
@@ -401,6 +436,7 @@ void main() {
expect(args['title'], 'New chat');
expect(args['initialValue'], resume ? 'Unfinished chat' : '');
expect(args['selectedAssistant'], resume ? 'agent' : isNull);
expect(args['selectedProject'], resume ? 'mobile' : isNull);
expect(args['fieldsLocked'], resume);
expect(args['error'], resume ? contains('Resume') : isNull);
expect(

View File

@@ -79,7 +79,12 @@ void main() {
),
);
MeSync service() => MeSync(RelaySessionNotifier(), config, prefs: prefs);
const intent = MeChatCreation('same-id', 'Private name', 'agent');
const intent = MeChatCreation(
'same-id',
'Private name',
'agent',
project: 'mobile',
);
var created = false, member = false, placed = false, crash = true;
final writes = [0, 0, 0];
void boundary(int point) {
@@ -118,6 +123,16 @@ void main() {
final pending = MeChatCreation.pending(restarted)!;
expect(pending.name, intent.name);
expect(pending.agent, intent.agent);
expect(pending.project, intent.project);
await expectLater(
MeChatCreation(
intent.id,
intent.name,
intent.agent,
project: 'other',
).save(restarted),
throwsStateError,
);
expect(
prefs.getString(MeChatCreation.storageKey(restarted)),
isNot(contains(intent.name)),