diff --git a/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift b/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift index a21e4260a5..3261ba27f8 100644 --- a/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift +++ b/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift @@ -6,6 +6,7 @@ struct MeAgentAccessibilityTests { @MainActor static func main() { for locked in [false, true] { for empty in [false, true] { + let savedName = "Saved agent" var submitted: [String: String]? let controller = NativeProfileTextEditorViewController( title: "New chat", initialValue: "Chat", placeholder: "Chat name", @@ -15,7 +16,7 @@ struct MeAgentAccessibilityTests { channelDescription: nil, originalName: nil, originalDescription: nil, canEditDetails: true, canEditCanvas: false, canvasContent: "", canvasLoaded: false, - chatOptions: ["selectedAssistant": "agent", "presentationId": "test", "assistantsLoading": empty, "fieldsLocked": locked, "assistants": empty ? [] : [["id": "agent", "name": "Agent"]]], + chatOptions: ["selectedAssistant": "agent", "savedAssistant": ["id": "agent", "name": savedName], "presentationId": "test", "assistantsLoading": empty, "fieldsLocked": locked, "assistants": empty ? [] : [["id": "agent", "name": "Agent"]]], onCancel: {}, onSet: { submitted = $0 as? [String: String] } ) controller.loadViewIfNeeded() @@ -28,6 +29,9 @@ struct MeAgentAccessibilityTests { precondition(project.isEnabled == !locked) precondition(project.showsMenuAsPrimaryAction && project.menu != nil) let cell = controller.tableView(controller.tableView, cellForRowAt: IndexPath(row: 0, section: 2)) + let content = cell.contentConfiguration as! UIListContentConfiguration + precondition(content.text == (empty ? (locked ? savedName : "Choose an agent") : "Agent")) + precondition((content.image != nil) == (locked || !empty)) let disabled = locked || empty precondition(cell.accessibilityTraits.contains(.notEnabled) == disabled) precondition(cell.accessibilityTraits.contains(.button) == !disabled) @@ -58,6 +62,9 @@ struct MeAgentAccessibilityTests { "presentationId": "test", "assistants": empty ? [] : [["id": "agent", "name": "Agent"]], ]) + let refreshedCell = controller.tableView(controller.tableView, cellForRowAt: IndexPath(row: 0, section: 2)) + let refreshed = refreshedCell.contentConfiguration as! UIListContentConfiguration + precondition(refreshed.text == (empty ? (locked ? savedName : "Choose an agent") : "Agent")) precondition(controller.navigationItem.rightBarButtonItem?.isEnabled == canSubmit) _ = controller.perform(NSSelectorFromString("setTapped")) precondition(submitted?["assistant"] == (canSubmit ? "agent" : nil)) diff --git a/mobile/ios/Runner/NativeProfileTextEditor.swift b/mobile/ios/Runner/NativeProfileTextEditor.swift index 317f64563b..c8544b926f 100644 --- a/mobile/ios/Runner/NativeProfileTextEditor.swift +++ b/mobile/ios/Runner/NativeProfileTextEditor.swift @@ -632,8 +632,13 @@ private final class NativeProfileTextEditorViewController: cell.accessoryView = button } else if isChatEditor && indexPath.section == 2 { var content = cell.defaultContentConfiguration() - let selected = assistants.first { $0["id"] == selectedAssistant } + let saved = chatOptions?["savedAssistant"] as? [String: String] + let fallback = fieldsLocked ? selectedAssistant.map { + saved.flatMap { $0["id"] == selectedAssistant ? $0 : nil } ?? ["id": $0, "name": $0] + } : nil + let selected = assistants.first { $0["id"] == selectedAssistant } ?? fallback content.text = selected?["name"] ?? "Choose an agent" + content.textProperties.numberOfLines = 0 if let selected { content.image = selectedAssistantAvatar ?? MeAssistantListController.avatarImage(selected, photo: nil) } 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 1b9a69f1ea..9bf932e38c 100644 --- a/mobile/lib/features/channels/me/native_new_me_chat.dart +++ b/mobile/lib/features/channels/me/native_new_me_chat.dart @@ -76,6 +76,19 @@ Future<(Channel, String)?> _showNewMeChat( .toARGB32(), 'containerCornerRadius': Radii.container, 'selectedAssistant': assistant, + if (retry && assistant != null) + 'savedAssistant': { + 'id': assistant, + 'name': + ref.read(userCacheProvider)[assistant]?.label ?? + directory + ?.where((a) => a.pubkey == assistant) + .firstOrNull + ?.displayName ?? + assistant, + 'avatarUrl': + ref.read(userCacheProvider)[assistant]?.avatarUrl ?? '', + }, 'selectedProject': project, 'projects': [ for (final group diff --git a/mobile/lib/shared/me/me_chat_creation.dart b/mobile/lib/shared/me/me_chat_creation.dart index 4fd92da73a..f366fb0df5 100644 --- a/mobile/lib/shared/me/me_chat_creation.dart +++ b/mobile/lib/shared/me/me_chat_creation.dart @@ -50,7 +50,7 @@ class MeChatCreation { previous.project != project)) { throw StateError('Resume the pending chat before starting another.'); } - if (previous != null) return; + // A matching cached value does not prove a previous disk write succeeded. if (sync.prefs == null || !await sync.prefs!.setString( storageKey(sync), diff --git a/mobile/test/features/channels/me/solo_creation_tags_test.dart b/mobile/test/features/channels/me/solo_creation_tags_test.dart index 181bd9c819..1c3fa33708 100644 --- a/mobile/test/features/channels/me/solo_creation_tags_test.dart +++ b/mobile/test/features/channels/me/solo_creation_tags_test.dart @@ -157,6 +157,7 @@ void main() { final draft = Completer>(); String? presentationId; Map? agentUpdate; + Object? savedAssistant; var forms = 0; tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(bridge, ( call, @@ -166,7 +167,9 @@ void main() { return null; } if (++forms > 1) return null; - presentationId = (call.arguments as Map)['presentationId'] as String; + final arguments = call.arguments as Map; + presentationId = arguments['presentationId'] as String; + savedAssistant = arguments['savedAssistant']; return draft.future; }); addTearDown( @@ -216,6 +219,13 @@ void main() { await tester.pumpAndSettle(); } expect(forms, 1, reason: 'Compose opens even while agents are loading'); + if (recoverExisting) { + expect(savedAssistant, { + 'id': agent.public, + 'name': agent.public, + 'avatarUrl': '', + }); + } directory.complete([ AgentDirectoryEntry(pubkey: agent.public, displayName: 'Owned agent'), ]); diff --git a/mobile/test/shared/me/me_chat_creation_test.dart b/mobile/test/shared/me/me_chat_creation_test.dart index 7aac3f116f..a7e79ccdc6 100644 --- a/mobile/test/shared/me/me_chat_creation_test.dart +++ b/mobile/test/shared/me/me_chat_creation_test.dart @@ -1,11 +1,59 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; import 'package:buzz/shared/me/me_chat_creation.dart'; import 'package:buzz/shared/me/me_sync.dart'; import 'package:buzz/shared/relay/relay.dart'; void main() { + test( + 'retry confirms persistence after a failed cached journal write', + () async { + SharedPreferences.setMockInitialValues({}); + final store = _FailingStore(); + final previousStore = SharedPreferencesStorePlatform.instance; + SharedPreferencesStorePlatform.instance = store; + addTearDown( + () => SharedPreferencesStorePlatform.instance = previousStore, + ); + final prefs = await SharedPreferences.getInstance(); + final sync = MeSync( + RelaySessionNotifier(), + RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Keys.generate().nsec, + ), + prefs: prefs, + ); + const intent = MeChatCreation('id', 'Name', 'agent'); + var reachedRelay = false; + Future run() => intent.complete( + sync, + findChannel: () async { + reachedRelay = true; + await prefs.reload(); + expect(MeChatCreation.pending(sync)?.id, intent.id); + return null; + }, + createChannel: () async => throw StateError('Simulated process stop'), + hasAgent: () async => false, + addAgent: () async {}, + isPlaced: () async => false, + place: () async {}, + ); + await expectLater(run(), throwsStateError); + expect(reachedRelay, isFalse); + expect( + MeChatCreation.pending(sync)?.id, + intent.id, + ); // Failed write remains cached. + await expectLater(run(), throwsStateError); + expect(reachedRelay, isTrue); + await prefs.reload(); + expect(MeChatCreation.pending(sync)?.id, intent.id); + }, + ); test('missing durable storage blocks every relay side effect', () async { final sync = MeSync( RelaySessionNotifier(), @@ -150,3 +198,16 @@ void main() { }); } } + +class _FailingStore extends InMemorySharedPreferencesStore { + _FailingStore() : super.empty(); + bool failNext = true; + @override + Future setValue(String valueType, String key, Object value) async { + if (failNext && key.startsWith('flutter.me-chat-creation:')) { + failNext = false; + return false; + } + return super.setValue(valueType, key, value); + } +}