mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user