diff --git a/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift b/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift index b3618230fe..4b5bdcc414 100644 --- a/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift +++ b/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift @@ -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) diff --git a/mobile/ios/Runner/NativeProfileTextEditor.swift b/mobile/ios/Runner/NativeProfileTextEditor.swift index 51f400b963..a771c7aacb 100644 --- a/mobile/ios/Runner/NativeProfileTextEditor.swift +++ b/mobile/ios/Runner/NativeProfileTextEditor.swift @@ -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" diff --git a/mobile/lib/features/channels/me/native_new_me_chat.dart b/mobile/lib/features/channels/me/native_new_me_chat.dart index 2192fed74b..f4749227de 100644 --- a/mobile/lib/features/channels/me/native_new_me_chat.dart +++ b/mobile/lib/features/channels/me/native_new_me_chat.dart @@ -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 _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 _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( @@ -166,8 +183,12 @@ Future _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)), ); } diff --git a/mobile/lib/features/channels/me/new_me_chat.dart b/mobile/lib/features/channels/me/new_me_chat.dart index bdf8ad3758..57e83407b2 100644 --- a/mobile/lib/features/channels/me/new_me_chat.dart +++ b/mobile/lib/features/channels/me/new_me_chat.dart @@ -8,6 +8,8 @@ class _NewMeChat extends HookConsumerWidget { final pending = useMemoized(() => MeChatCreation.pending(sync)); final name = useTextEditingController(text: pending?.name ?? ''); final selected = useState(pending?.agent); + final project = useState(pending?.project); + final groups = ref.watch(meGroupsProvider); final channelId = useMemoized(() => pending?.id ?? const Uuid().v4()); final frozenName = useState(pending?.name); final frozenAgent = useState(pending?.agent); @@ -52,6 +54,34 @@ class _NewMeChat extends HookConsumerWidget { onChanged: (_) => error.value = null, ), const SizedBox(height: Grid.xs), + DropdownButtonFormField( + 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 ?? >[]) + 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( diff --git a/mobile/lib/shared/me/me_chat_creation.dart b/mobile/lib/shared/me/me_chat_creation.dart index 74f0488db8..8fb657b6ac 100644 --- a/mobile/lib/shared/me/me_chat_creation.dart +++ b/mobile/lib/shared/me/me_chat_creation.dart @@ -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 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( diff --git a/mobile/test/features/channels/me/chat_preflight_test.dart b/mobile/test/features/channels/me/chat_preflight_test.dart index 2038a0299c..a136d55b17 100644 --- a/mobile/test/features/channels/me/chat_preflight_test.dart +++ b/mobile/test/features/channels/me/chat_preflight_test.dart @@ -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')), diff --git a/mobile/test/features/channels/me/me_page_test.dart b/mobile/test/features/channels/me/me_page_test.dart index 60a7a895db..64911f1187 100644 --- a/mobile/test/features/channels/me/me_page_test.dart +++ b/mobile/test/features/channels/me/me_page_test.dart @@ -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(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( diff --git a/mobile/test/shared/me/me_chat_creation_test.dart b/mobile/test/shared/me/me_chat_creation_test.dart index a9752817a0..7aac3f116f 100644 --- a/mobile/test/shared/me/me_chat_creation_test.dart +++ b/mobile/test/shared/me/me_chat_creation_test.dart @@ -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)),