Merge main and preserve asynchronous Solo agent loading

Keep project selection from main and refresh the agent row at its new section. Cover the combined native loading flow in the existing simulator regression.

Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-10-22 15:39:00 -07:00
8 changed files with 205 additions and 24 deletions

View File

@@ -14,15 +14,39 @@ struct MeAgentAccessibilityTests {
channelDescription: nil, originalName: nil, originalDescription: nil,
canEditDetails: true, canEditCanvas: false, canvasContent: "",
canvasLoaded: false,
chatOptions: ["fieldsLocked": locked, "assistants": empty ? [] : [["id": "agent", "name": "Agent"]]],
chatOptions: ["presentationId": "test", "assistantsLoading": empty, "fieldsLocked": locked, "assistants": empty ? [] : [["id": "agent", "name": "Agent"]]],
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)
precondition((cell.selectionStyle == .none) == disabled)
if empty && !locked {
controller.tableView.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
controller.tableView.reloadData()
controller.tableView.layoutIfNeeded()
precondition(controller.tableView(controller.tableView, titleForFooterInSection: 1) == nil)
precondition(controller.tableView(controller.tableView, titleForFooterInSection: 2) == "Loading agents…")
controller.updateChatAssistants([
"presentationId": "test",
"assistants": [["id": "agent", "name": "Agent"]],
])
controller.tableView.layoutIfNeeded()
let updated = controller.tableView.cellForRow(at: IndexPath(row: 0, section: 2))!
precondition(updated.accessibilityTraits.contains(.button))
precondition(!updated.accessibilityTraits.contains(.notEnabled))
precondition(controller.tableView(controller.tableView, titleForFooterInSection: 2) == nil)
}
}
}
avatarTests()

View File

@@ -256,6 +256,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]] {
@@ -346,6 +350,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)
@@ -440,7 +445,7 @@ private final class NativeProfileTextEditorViewController:
chatOptions?["assistants"] = arguments["assistants"]
chatOptions?["assistantsLoading"] = false
chatOptions?["assistantError"] = arguments["assistantError"]
tableView.reloadSections(IndexSet(integer: 1), with: .none)
tableView.reloadSections(IndexSet(integer: 2), with: .none)
updateNavigation()
}
@@ -495,7 +500,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 ?? ""])
@@ -526,13 +531,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, avatars: assistantAvatars) { [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)
@@ -552,10 +557,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 chatOptions?["assistantsLoading"] as? Bool == true { return "Loading agents…" }
if let error = chatOptions?["assistantError"] as? String { return error }
if let error = chatOptions?["error"] as? String { return error }
@@ -573,7 +578,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
}
@@ -589,6 +594,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) {
@@ -75,6 +76,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': _nativeMeAssistants(directory ?? []),
@@ -120,10 +127,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) {
@@ -150,9 +166,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 actions = ref.read(channelActionsProvider);
final preflight = await Future.wait<Object?>([
@@ -167,7 +184,7 @@ Future<Channel> _createMeChat(
}
final head = preflight[1] as NostrEvent?;
(head == null ? const MeGroups() : sync.decode(head))
.place(channelId)
.place(channelId, group: project)
.encode(sync.community);
return intent.complete<Channel>(
sync,
@@ -189,9 +206,13 @@ 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 (previous != null) return;
@@ -50,7 +56,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.');
@@ -63,7 +74,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

@@ -73,7 +73,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(
@@ -125,6 +129,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)),