diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index eeb5cd509b..16fd64a403 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -262,7 +262,6 @@ jobs: /bin/bash scripts/test-mobile-pagination-accessibility.sh "$device" /bin/bash scripts/test-mobile-carousel.sh "$device" /bin/bash scripts/test-mobile-member-picker.sh "$device" - /bin/bash scripts/test-mobile-me-agent-accessibility.sh "$device" if ! wait "$build"; then cat "$build_log" exit 1 diff --git a/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift b/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift deleted file mode 100644 index c1baec99a0..0000000000 --- a/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift +++ /dev/null @@ -1,232 +0,0 @@ -import Flutter -import UIKit - -@main -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", - multiline: false, pageBackgroundColor: .systemGroupedBackground, - containerBackgroundColor: .secondarySystemGroupedBackground, - containerCornerRadius: 16, allowUnchangedSubmission: false, - channelDescription: nil, originalName: nil, originalDescription: nil, - canEditDetails: true, canEditCanvas: false, canvasContent: "", - canvasLoaded: false, - 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() - 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 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) - precondition((cell.selectionStyle == .none) == disabled) - let canSubmit = locked || !empty - precondition(controller.navigationItem.rightBarButtonItem?.isEnabled == canSubmit) - // Invoke the action even when disabled to check its independent gate. - _ = controller.perform(NSSelectorFromString("setTapped")) - precondition(submitted?["assistant"] == (canSubmit ? "agent" : nil)) - 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) - } - submitted = nil - controller.updateChatAssistants([ - "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)) - } - } - messageTests() - avatarTests() - print("Me agent accessibility and bounded avatar loading passed") - } -} - -private final class AvatarProtocol: URLProtocol { - static let lock = NSLock() - static var starts = 0 - static var stops = 0 - static var png = Data() - static func counts() -> (Int, Int) { - lock.lock(); defer { lock.unlock() } - return (starts, stops) - } - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } - override func startLoading() { - Self.lock.lock(); Self.starts += 1; Self.lock.unlock() - let path = request.url!.path - if path.hasPrefix("/hold") { return } - let headers = ["Content-Type": path == "/text" ? "text/html" : "image/png", - "Content-Length": path == "/large" ? "9000000" : "-1"] - client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: path == "/error" ? 404 : 200, - httpVersion: nil, headerFields: headers)!, cacheStoragePolicy: .notAllowed) - if path == "/chunked" { - for _ in 0..<3 { client?.urlProtocol(self, didLoad: Data(repeating: 0, count: 1024 * 1024)) } - } else { - client?.urlProtocol(self, didLoad: Self.png) - } - client?.urlProtocolDidFinishLoading(self) - } - override func stopLoading() { - Self.lock.lock(); Self.stops += 1; Self.lock.unlock() - } -} - -@MainActor private func avatarTests() { - func pump(until predicate: () -> Bool) { - let deadline = Date().addingTimeInterval(4) - while !predicate() && Date() < deadline { - RunLoop.main.run(until: Date().addingTimeInterval(0.01)) - } - precondition(predicate(), "Avatar operation timed out") - } - let config = URLSessionConfiguration.ephemeral - config.protocolClasses = [AvatarProtocol.self] - let loader = MeAvatarLoader(configuration: config) - let format = UIGraphicsImageRendererFormat() - format.scale = 1 - AvatarProtocol.png = UIGraphicsImageRenderer(size: CGSize(width: 240, height: 240), format: format).pngData { _ in - UIColor.red.setFill(); UIRectFill(CGRect(x: 0, y: 0, width: 240, height: 240)) - } - // No eager request on presenting even a thousand agents; only visible rows load. - let controller = MeAssistantListController(assistants: (0..<1000).map { - ["id": "agent-\($0)", "name": "Agent \($0)", "avatarUrl": "https://avatar.test/hold/\($0)"] - }, selected: nil, pageColor: .white, rowColor: .white, avatars: loader, onSelect: { _, _ in }) - controller.loadViewIfNeeded() - precondition(AvatarProtocol.counts().0 == 0) - for row in 0..<20 { - let path = IndexPath(row: row, section: 0) - let cell = controller.tableView(controller.tableView, cellForRowAt: path) - controller.tableView(controller.tableView, willDisplay: cell, forRowAt: path) - } - pump { AvatarProtocol.counts().0 == MeAvatarLoader.concurrency } - controller.viewDidDisappear(false) - pump { AvatarProtocol.counts().1 == MeAvatarLoader.concurrency } - precondition(AvatarProtocol.counts().0 == MeAvatarLoader.concurrency) - - for path in ["large", "chunked", "error", "text", "ok"] { - var done = false - var result: UIImage? - loader.load(URL(string: "https://avatar.test/\(path)")!) { image in - result = image; done = true - } - pump { done } - precondition((result != nil) == (path == "ok")) - if let result { precondition(max(result.size.width, result.size.height) <= 120) } - } - let image = loader.image(for: URL(string: "https://avatar.test/ok")) - precondition(image != nil) - let rendered = MeAssistantListController.avatarImage(["name": "Agent"], photo: image) - precondition(rendered.size == CGSize(width: 40, height: 40)) - let visibleLoader = MeAvatarLoader(configuration: config) - let visible = MeAssistantListController(assistants: [["id": "visible", "name": "Visible", "avatarUrl": "https://avatar.test/visible"]], - selected: nil, pageColor: .white, rowColor: .white, avatars: visibleLoader, onSelect: { _, _ in }) - visible.loadViewIfNeeded() - visible.tableView.frame = CGRect(x: 0, y: 0, width: 320, height: 400) - visible.tableView.reloadData() - visible.tableView.layoutIfNeeded() - pump { visibleLoader.image(for: URL(string: "https://avatar.test/visible")) != nil } - let cell = visible.tableView.cellForRow(at: IndexPath(row: 0, section: 0))! - let content = cell.contentConfiguration as! UIListContentConfiguration - precondition(content.image?.pngData() == MeAssistantListController.avatarImage(["name": "Visible"], photo: image).pngData()) - visible.viewDidDisappear(false) - // Source metadata is inspected before ImageIO is allowed to decode large pixels. - autoreleasepool { - let huge = UIGraphicsImageRenderer(size: CGSize(width: 4100, height: 4100), format: format).pngData { _ in } - precondition(MeAvatarLoader.thumbnail(huge) == nil) - } - loader.cancelAll() -} - -@MainActor private func messageTests() { - let message = String(repeating: "Plan my trip. ", count: 20) - for locked in [false, true] { - var submitted: [String: String]? - let controller = NativeProfileTextEditorViewController( - title: "New chat", initialValue: message, placeholder: "Message", - multiline: true, pageBackgroundColor: .systemGroupedBackground, - containerBackgroundColor: .secondarySystemGroupedBackground, - containerCornerRadius: 16, allowUnchangedSubmission: false, - channelDescription: nil, originalName: nil, originalDescription: nil, - canEditDetails: true, canEditCanvas: false, canvasContent: "", - canvasLoaded: false, - chatOptions: ["messageMode": true, "presentationId": "message", "fieldsLocked": locked, - "selectedAssistant": locked ? "saved" : NSNull(), - "savedAssistant": ["id": "saved", "name": "Saved agent"], "assistants": []], - onCancel: {}, onSet: { submitted = $0 as? [String: String] } - ) - controller.loadViewIfNeeded() - let cell = controller.tableView(controller.tableView, cellForRowAt: IndexPath(row: 0, section: 0)) - let input = cell.contentView.subviews.compactMap { $0 as? UITextView }.first! - precondition(input.accessibilityLabel == "Message") - precondition(input.isEditable == !locked) - precondition(controller.navigationItem.rightBarButtonItem?.title == (locked ? "Retry" : "Send")) - precondition(controller.navigationItem.rightBarButtonItem?.isEnabled == locked) - controller.updateChatAssistants(["presentationId": "stale", "defaultAssistant": "bestie", - "assistants": [["id": "bestie", "name": "Bestie"]]]) - precondition(controller.navigationItem.rightBarButtonItem?.isEnabled == locked) - controller.updateChatAssistants(["presentationId": "message", "defaultAssistant": "missing", - "assistants": [["id": "bestie", "name": "Bestie"]]]) - precondition(controller.navigationItem.rightBarButtonItem?.isEnabled == locked) - controller.updateChatAssistants(["presentationId": "message", "defaultAssistant": "bestie", - "assistants": [["id": "bestie", "name": "Bestie"]]]) - precondition(controller.navigationItem.rightBarButtonItem?.isEnabled == true) - controller.updateChatAssistants(["presentationId": "message", "defaultAssistant": "other", - "assistants": [["id": "bestie", "name": "Bestie"], ["id": "other", "name": "Other"]]]) - if !locked { - let navigation = UINavigationController(rootViewController: controller) - controller.tableView(controller.tableView, didSelectRowAt: IndexPath(row: 0, section: 2)) - let picker = navigation.topViewController as! MeAssistantListController - picker.tableView(picker.tableView, didSelectRowAt: IndexPath(row: 0, section: 0)) - controller.updateChatAssistants(["presentationId": "message", "defaultAssistant": "other", - "assistants": [["id": "bestie", "name": "Bestie"], ["id": "other", "name": "Other"]]]) - } - _ = controller.perform(NSSelectorFromString("setTapped")) - precondition(submitted?["name"] == message) - precondition(submitted?["assistant"] == (locked ? "saved" : "bestie")) - if !locked { - submitted = nil - input.text = String(repeating: "🐝", count: 2049) - controller.textViewDidChange(input) - _ = controller.perform(NSSelectorFromString("setTapped")) - precondition(submitted == nil) - precondition(input.isEditable) - } - precondition(controller.tableView(controller.tableView, heightForRowAt: IndexPath(row: 0, section: 1)) == 52) - } -} diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 310628f0e6..c76613789a 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -229,4 +229,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: ed10001903303a0ae660957e1d727012a3136046 -COCOAPODS: 1.16.2 +COCOAPODS: 1.17.0 diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index d8fe37443e..b1804e39fe 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -14,7 +14,6 @@ BBAA00000000000000000011 /* IdentityFilePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBAA00000000000000000012 /* IdentityFilePlugin.swift */; }; BBAA00000000000000000001 /* HostedHpkeBackup.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBAA00000000000000000002 /* HostedHpkeBackup.swift */; }; DAAB00000000000000000001 /* NativeTabler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAAB00000000000000000002; }; - 4A71C0192F40D00100A17E02 /* MeAvatarLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01A2F40D00100A17E02 /* MeAvatarLoader.swift */; }; C0AF00000000000000000001 /* NativeConfirmationDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AF00000000000000000002 /* NativeConfirmationDialog.swift */; }; C0AF00000000000000000021 /* NativePresenceMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AF00000000000000000022 /* NativePresenceMenu.swift */; }; C0AF00000000000000000011 /* NativeAddMembersSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AF00000000000000000012 /* NativeAddMembersSheet.swift */; }; @@ -111,7 +110,6 @@ BBAA00000000000000000012 /* IdentityFilePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityFilePlugin.swift; sourceTree = ""; }; BBAA00000000000000000002 /* HostedHpkeBackup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostedHpkeBackup.swift; sourceTree = ""; }; DAAB00000000000000000002 /* NativeTabler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTabler.swift; sourceTree = ""; }; - 4A71C01A2F40D00100A17E02 /* MeAvatarLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeAvatarLoader.swift; sourceTree = ""; }; C0AF00000000000000000002 /* NativeConfirmationDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeConfirmationDialog.swift; sourceTree = ""; }; C0AF00000000000000000022 /* NativePresenceMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePresenceMenu.swift; sourceTree = ""; }; C0AF00000000000000000012 /* NativeAddMembersSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAddMembersSheet.swift; sourceTree = ""; }; @@ -323,7 +321,6 @@ 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */, 7B81D01A2F40D00100A17E01 /* NativeCommunityEntry.swift */, 7B81D01A2F40D00100A17E91 /* NativeProfileSetup.swift */, - 4A71C01A2F40D00100A17E02 /* MeAvatarLoader.swift */, 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */, 4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */, CA8300022F50000100A17E01 /* IosMediaCarousel.swift */, @@ -671,7 +668,6 @@ 4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */, 7B81D0192F40D00100A17E01 /* NativeCommunityEntry.swift in Sources */, 7B81D0192F40D00100A17E91 /* NativeProfileSetup.swift in Sources */, - 4A71C0192F40D00100A17E02 /* MeAvatarLoader.swift in Sources */, 4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */, 4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */, CA8300012F50000100A17E01 /* IosMediaCarousel.swift in Sources */, diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000..b3c85e18c2 --- /dev/null +++ b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "cf0ceb033b4e7c51448393bb935a48ab1346299e1d2e41c5ef7e1095f5820443", + "pins" : [ + { + "identity" : "swiftdraw", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swhitty/SwiftDraw.git", + "state" : { + "revision" : "82699f4bdf2f075793cfa0d392eb27c59b41a3fe", + "version" : "0.29.0" + } + } + ], + "version" : 3 +} diff --git a/mobile/ios/Runner/MeAvatarLoader.swift b/mobile/ios/Runner/MeAvatarLoader.swift deleted file mode 100644 index a9bec24740..0000000000 --- a/mobile/ios/Runner/MeAvatarLoader.swift +++ /dev/null @@ -1,138 +0,0 @@ -import UIKit -import ImageIO - -/// Visible-row avatar loading with bounded network, decoded memory, and lifetime. -/// All entry points and URLSession callbacks run on the main queue. -final class MeAvatarLoader: NSObject, URLSessionDataDelegate, @unchecked Sendable { - static let byteLimit = 2 * 1024 * 1024 - static let pixelLimit = 16_000_000 - static let concurrency = 3 - private let configuration: URLSessionConfiguration - private var session: URLSession? - private let cache = NSCache() - private struct Request { - let url: URL - let completion: (UIImage?) -> Void - var task: URLSessionDataTask? - var data = Data() - } - private var requests: [UUID: Request] = [:] - private var queue: [UUID] = [] - - init(configuration: URLSessionConfiguration = .ephemeral) { - self.configuration = configuration - super.init() - cache.totalCostLimit = 4 * 1024 * 1024 - cache.countLimit = 128 - } - - func image(for url: URL?) -> UIImage? { - guard let url else { return nil } - return cache.object(forKey: url as NSURL) - } - - @discardableResult - func load(_ url: URL, completion: @escaping (UIImage?) -> Void) -> UUID { - let id = UUID() - guard ["http", "https"].contains(url.scheme?.lowercased() ?? "") else { - completion(nil) - return id - } - if let cached = image(for: url) { - completion(cached) - return id - } - requests[id] = Request(url: url, completion: completion) - queue.append(id) - startQueued() - return id - } - - func cancel(_ id: UUID) { - requests.removeValue(forKey: id)?.task?.cancel() - queue.removeAll { $0 == id } - startQueued() - } - - func cancelAll() { - queue.removeAll() - requests.removeAll() - session?.invalidateAndCancel() - session = nil - } - - private func startQueued() { - while requests.values.filter({ $0.task != nil }).count < Self.concurrency, - !queue.isEmpty { - let id = queue.removeFirst() - guard let request = requests[id] else { continue } - if session == nil { - configuration.urlCache = nil - configuration.timeoutIntervalForRequest = 15 - session = URLSession(configuration: configuration, delegate: self, delegateQueue: .main) - } - let task = session!.dataTask(with: request.url) - requests[id]?.task = task - task.resume() - } - } - - private func id(for task: URLSessionTask) -> UUID? { - requests.first { $0.value.task === task }?.key - } - - private func finish(_ id: UUID, image: UIImage?) { - guard let request = requests.removeValue(forKey: id) else { return } - if let image, let cgImage = image.cgImage { - cache.setObject(image, forKey: request.url as NSURL, cost: cgImage.bytesPerRow * cgImage.height) - } - request.completion(image) - startQueued() - } - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, - didReceive response: URLResponse, - completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { - guard let id = id(for: dataTask) else { completionHandler(.cancel); return } - guard let response = response as? HTTPURLResponse, - (200...299).contains(response.statusCode), - response.mimeType?.hasPrefix("image/") == true, - response.expectedContentLength <= Self.byteLimit else { - completionHandler(.cancel) - finish(id, image: nil) - return - } - completionHandler(.allow) - } - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - guard let id = id(for: dataTask), let request = requests[id] else { return } - guard data.count <= Self.byteLimit - request.data.count else { - dataTask.cancel() - finish(id, image: nil) - return - } - requests[id]?.data.append(data) - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - guard let id = id(for: task), let request = requests[id] else { return } - finish(id, image: error == nil ? Self.thumbnail(request.data) : nil) - } - - static func thumbnail(_ data: Data) -> UIImage? { - guard let source = CGImageSourceCreateWithData(data as CFData, - [kCGImageSourceShouldCache: false] as CFDictionary), - let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], - let width = properties[kCGImagePropertyPixelWidth] as? Int, - let height = properties[kCGImagePropertyPixelHeight] as? Int, - width > 0, height > 0, width <= pixelLimit / height, - let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [ - kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceCreateThumbnailWithTransform: true, - kCGImageSourceThumbnailMaxPixelSize: 120, - kCGImageSourceShouldCacheImmediately: true, - ] as CFDictionary) else { return nil } - return UIImage(cgImage: image) - } -} diff --git a/mobile/ios/Runner/NativeProfileTextEditor.swift b/mobile/ios/Runner/NativeProfileTextEditor.swift index 23f8a52ee1..e0b0209b04 100644 --- a/mobile/ios/Runner/NativeProfileTextEditor.swift +++ b/mobile/ios/Runner/NativeProfileTextEditor.swift @@ -28,18 +28,7 @@ final class NativeProfileTextEditorCoordinator: NSObject, _ call: FlutterMethodCall, result: @escaping FlutterResult ) { - if call.method == "updateChatAssistants" { - DispatchQueue.main.async { [weak self] in - if let arguments = call.arguments as? [String: Any], - let navigation = self?.presentedController as? UINavigationController, - let editor = navigation.viewControllers.first as? NativeProfileTextEditorViewController { - editor.updateChatAssistants(arguments) - } - result(nil) - } - return - } - guard call.method == "present" || call.method == "presentChannel" || call.method == "presentChat" || call.method == "presentProject" else { + guard call.method == "present" || call.method == "presentChannel" || call.method == "presentProject" else { result(FlutterMethodNotImplemented) return } @@ -84,8 +73,7 @@ final class NativeProfileTextEditorCoordinator: NSObject, canEditCanvas: arguments["canEditCanvas"] as? Bool ?? false, canvasContent: arguments["canvasContent"] as? String ?? "", canvasLoaded: arguments["canvasLoaded"] as? Bool ?? false, - chatOptions: (call.method == "presentChat" || call.method == "presentProject") - ? arguments.merging(["projectOnly": call.method == "presentProject"]) { _, new in new } : nil, + isProjectEditor: call.method == "presentProject", result: result ) } @@ -109,7 +97,7 @@ final class NativeProfileTextEditorCoordinator: NSObject, canEditCanvas: Bool, canvasContent: String, canvasLoaded: Bool, - chatOptions: [String: Any]?, + isProjectEditor: Bool, result: @escaping FlutterResult ) { guard presentedController == nil else { @@ -153,7 +141,7 @@ final class NativeProfileTextEditorCoordinator: NSObject, canEditCanvas: canEditCanvas, canvasContent: canvasContent, canvasLoaded: canvasLoaded, - chatOptions: chatOptions, + isProjectEditor: isProjectEditor, onCancel: { [weak self] in self?.finish(value: nil) }, onSet: { [weak self] value in self?.finish(value: value) } ) @@ -252,24 +240,8 @@ private final class NativeProfileTextEditorViewController: private var hasCanvas: Bool { canvasLoaded && !canvasContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - private var chatOptions: [String: Any]? + private let isProjectEditor: Bool private var hasFocusedEditor = false - private var selectedAssistantAvatar: UIImage? - private var selectedAssistant: String? - private var assistantChosenManually = false - private var selectedProject: String? - private var projects: [[String: String]] { - chatOptions?["projects"] as? [[String: String]] ?? [] - } - private var isChatEditor: Bool { chatOptions != nil } - private var isMessageEditor: Bool { chatOptions?["messageMode"] as? Bool ?? false } - private var isProjectEditor: Bool { chatOptions?["projectOnly"] as? Bool ?? false } - private var assistants: [[String: String]] { - chatOptions?["assistants"] as? [[String: String]] ?? [] - } - // Keep decoded artwork while switching between the form and its agent list. - private let assistantAvatars = MeAvatarLoader() - private var fieldsLocked: Bool { chatOptions?["fieldsLocked"] as? Bool ?? false } private var isChannelEditor: Bool { channelDescription != nil } private lazy var textField: UITextField = { @@ -281,7 +253,7 @@ private final class NativeProfileTextEditorViewController: field.adjustsFontForContentSizeCategory = true field.clearButtonMode = .whileEditing field.returnKeyType = isChannelEditor ? .next : .done - field.isEnabled = canEditDetails && !fieldsLocked + field.isEnabled = canEditDetails field.accessibilityLabel = placeholder field.autocapitalizationType = .sentences field.delegate = self @@ -293,7 +265,7 @@ private final class NativeProfileTextEditorViewController: let view = UITextView() view.translatesAutoresizingMaskIntoConstraints = false view.text = channelDescription ?? initialValue - view.isEditable = canEditDetails && !fieldsLocked + view.isEditable = canEditDetails view.accessibilityLabel = isChannelEditor ? "Description" : placeholder view.font = .preferredFont(forTextStyle: .body) view.adjustsFontForContentSizeCategory = true @@ -332,7 +304,7 @@ private final class NativeProfileTextEditorViewController: canEditCanvas: Bool, canvasContent: String, canvasLoaded: Bool, - chatOptions: [String: Any]?, + isProjectEditor: Bool, onCancel: @escaping () -> Void, onSet: @escaping (Any) -> Void ) { @@ -350,9 +322,7 @@ private final class NativeProfileTextEditorViewController: self.canEditCanvas = canEditCanvas self.canvasContent = canvasContent self.canvasLoaded = canvasLoaded - self.chatOptions = chatOptions - self.selectedAssistant = chatOptions?["selectedAssistant"] as? String - self.selectedProject = chatOptions?["selectedProject"] as? String + self.isProjectEditor = isProjectEditor self.onCancel = onCancel self.onSet = onSet super.init(style: .insetGrouped) @@ -377,7 +347,7 @@ private final class NativeProfileTextEditorViewController: action: #selector(cancelTapped) ) navigationItem.rightBarButtonItem = UIBarButtonItem( - title: isChatEditor ? (fieldsLocked ? "Retry" : (isMessageEditor ? "Send" : "Create")) : (isChannelEditor ? "Save" : "Set"), + title: isProjectEditor ? "Create" : (isChannelEditor ? "Save" : "Set"), style: .done, target: self, action: #selector(setTapped) @@ -401,9 +371,8 @@ private final class NativeProfileTextEditorViewController: } private var hasUnsavedChanges: Bool { - if isChatEditor { + if isProjectEditor { return !currentValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - || selectedAssistant != nil } if isChannelEditor { return allowUnchangedSubmission @@ -430,35 +399,12 @@ private final class NativeProfileTextEditorViewController: updateNavigation() } - func updateChatAssistants(_ arguments: [String: Any]) { - guard let token = arguments["presentationId"] as? String, - token == chatOptions?["presentationId"] as? String else { return } - chatOptions?["assistants"] = arguments["assistants"] - chatOptions?["assistantsLoading"] = false - chatOptions?["assistantError"] = arguments["assistantError"] - if !assistantChosenManually && !fieldsLocked && arguments.keys.contains("defaultAssistant") { - let bestie = arguments["defaultAssistant"] as? String - selectedAssistant = assistants.contains(where: { $0["id"] == bestie }) ? bestie : nil - selectedAssistantAvatar = nil - } - tableView.reloadSections(IndexSet(integer: 2), with: .none) - updateNavigation() - } - - // A durable retry keeps its saved recipient even if discovery hides it. - // Flutter revalidates ownership before any creation writes. - private var canSubmitAssistant: Bool { - guard let selectedAssistant else { return false } - return fieldsLocked || assistants.contains { $0["id"] == selectedAssistant } - } - private func updateNavigation() { navigationItem.rightBarButtonItem?.isEnabled = canEditDetails && hasUnsavedChanges && (!isChannelEditor || !canonicalChannelName.isEmpty) - && (!isChatEditor || ( + && (!isProjectEditor || ( !currentValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - && (isMessageEditor || currentValue.count <= 120) - && (isProjectEditor || canSubmitAssistant) + && currentValue.count <= 120 )) navigationController?.isModalInPresentation = hasUnsavedChanges placeholderLabel.isHidden = !textView.text.isEmpty @@ -498,18 +444,6 @@ private final class NativeProfileTextEditorViewController: let name = currentValue.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty, currentValue.count <= 120 else { return } onSet(name) - } else if isChatEditor { - if isMessageEditor && currentValue.trimmingCharacters(in: .whitespacesAndNewlines).utf8.count > 8192 { - let alert = UIAlertController(title: "Message too long", message: "Keep your first message under 8 KB.", preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .default)) - present(alert, animated: true) - return - } - guard let selectedAssistant, - canSubmitAssistant, - !currentValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - (isMessageEditor || currentValue.count <= 120) else { return } - onSet(["name": currentValue, "assistant": selectedAssistant, "project": selectedProject ?? ""]) } else if isChannelEditor { guard !canonicalChannelName.isEmpty else { return } onSet(["action": "save", "name": canonicalChannelName, "description": textView.text ?? ""]) @@ -540,19 +474,6 @@ private final class NativeProfileTextEditorViewController: override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - 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?.assistantChosenManually = true - self?.selectedAssistant = id - self?.tableView.reloadSections(IndexSet(integer: 2), with: .none) - self?.updateNavigation() - } - navigationController?.pushViewController(picker, animated: true) - return - } guard isChannelEditor && canEditCanvas else { return } if indexPath.section == 2 { editCanvasTapped() } if indexPath.section == 3 { removeCanvasTapped() } @@ -567,17 +488,7 @@ private final class NativeProfileTextEditorViewController: return false } - 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 == 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 } - return assistants.isEmpty - ? "No agents are available for this account. Connect an agent on desktop, then try again." - : nil - } + override func numberOfSections(in tableView: UITableView) -> Int { isChannelEditor ? (hasCanvas && canEditCanvas ? 4 : 3) : 1 } override func tableView( _ tableView: UITableView, @@ -588,7 +499,6 @@ private final class NativeProfileTextEditorViewController: _ tableView: UITableView, heightForRowAt indexPath: IndexPath ) -> CGFloat { - if isChatEditor && indexPath.section == 2 && selectedAssistant != nil { return 64 } if isChannelEditor && indexPath.section >= 2 { return UITableView.automaticDimension } return (isChannelEditor ? indexPath.section == 1 : multiline && indexPath.section == 0) ? 132 : 52 } @@ -603,54 +513,7 @@ private final class NativeProfileTextEditorViewController: cell.layer.cornerRadius = containerCornerRadius 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 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) - } - content.textProperties.font = .preferredFont(forTextStyle: .body) - cell.contentConfiguration = content - cell.accessoryType = .disclosureIndicator - cell.selectionStyle = fieldsLocked || assistants.isEmpty ? .none : .default - cell.accessibilityTraits = fieldsLocked || assistants.isEmpty ? .notEnabled : .button - } else if isChannelEditor && indexPath.section >= 2 { + if isChannelEditor && indexPath.section >= 2 { var content = cell.defaultContentConfiguration() content.textProperties.font = .preferredFont(forTextStyle: .body) if indexPath.section == 3 { @@ -708,112 +571,3 @@ private extension UIColor { ) } } - -/// A member-style list inside the New chat sheet, rather than an action menu. -private final class MeAssistantListController: UITableViewController { - private let assistants: [[String: String]] - private let selected: String? - private let onSelect: (String, UIImage) -> Void - private let pageColor: UIColor - private let rowColor: UIColor - private let avatars: MeAvatarLoader - private var requests: [IndexPath: UUID] = [:] - - init(assistants: [[String: String]], selected: String?, pageColor: UIColor, rowColor: UIColor, avatars: MeAvatarLoader = MeAvatarLoader(), onSelect: @escaping (String, UIImage) -> Void) { - self.assistants = assistants - self.selected = selected - self.onSelect = onSelect - self.pageColor = pageColor - self.rowColor = rowColor - self.avatars = avatars - super.init(style: .insetGrouped) - title = "Agent" - } - - required init?(coder: NSCoder) { nil } - - deinit { avatars.cancelAll() } - - override func viewDidLoad() { - super.viewDidLoad() - tableView.backgroundColor = pageColor - tableView.rowHeight = UITableView.automaticDimension - tableView.estimatedRowHeight = 64 - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - tableView.reloadData() - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - avatars.cancelAll() - requests.removeAll() - } - - private func avatarURL(_ assistant: [String: String]) -> URL? { - assistant["avatarUrl"].flatMap(URL.init(string:)) - } - - override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - let assistant = assistants[indexPath.row] - guard let url = avatarURL(assistant) else { return } - requests[indexPath] = avatars.load(url) { [weak self, weak cell] image in - guard self != nil, let cell, tableView.indexPath(for: cell) == indexPath else { return } - var content = cell.defaultContentConfiguration() - content.text = assistant["name"] ?? "Agent" - content.image = Self.avatarImage(assistant, photo: image) - cell.contentConfiguration = content - } - } - - override func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - if let request = requests.removeValue(forKey: indexPath) { avatars.cancel(request) } - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - assistants.count - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let assistant = assistants[indexPath.row] - let cell = UITableViewCell(style: .default, reuseIdentifier: nil) - var content = cell.defaultContentConfiguration() - let name = assistant["name"] ?? "Agent" - content.text = name - content.image = Self.avatarImage(assistant, photo: avatars.image(for: avatarURL(assistant))) - cell.backgroundColor = rowColor - cell.contentConfiguration = content - cell.accessoryType = assistant["id"] == selected ? .checkmark : .none - cell.accessibilityTraits = assistant["id"] == selected ? [.button, .selected] : .button - return cell - } - - static func avatarImage(_ assistant: [String: String], photo: UIImage?) -> UIImage { - let name = assistant["name"] ?? "Agent" - return UIGraphicsImageRenderer(size: CGSize(width: 40, height: 40)).image { _ in - let bounds = CGRect(x: 0, y: 0, width: 40, height: 40) - UIBezierPath(roundedRect: bounds, cornerRadius: 12).addClip() - UIColor.secondarySystemFill.setFill() - UIRectFill(bounds) - if let photo { - let artworkScale = CGFloat(Double(assistant["artworkScale"] ?? "1") ?? 1) - let scale = max(40 / photo.size.width, 40 / photo.size.height) * artworkScale - let size = CGSize(width: photo.size.width * scale, height: photo.size.height * scale) - photo.draw(in: CGRect(x: (40 - size.width) / 2, y: (40 - size.height) / 2, width: size.width, height: size.height)) - } else { - let initial = String(name.prefix(1)).uppercased() as NSString - let attributes: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 18, weight: .medium), .foregroundColor: UIColor.label] - let size = initial.size(withAttributes: attributes) - initial.draw(at: CGPoint(x: (40 - size.width) / 2, y: (40 - size.height) / 2), withAttributes: attributes) - } - }.withRenderingMode(.alwaysOriginal) - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let id = assistants[indexPath.row]["id"] else { return } - onSelect(id, Self.avatarImage(assistants[indexPath.row], photo: avatars.image(for: avatarURL(assistants[indexPath.row])))) - navigationController?.popViewController(animated: true) - } -} diff --git a/mobile/lib/features/channels/me/me_chat_draft.dart b/mobile/lib/features/channels/me/me_chat_draft.dart index aafc9024ef..f5bfe8c3da 100644 --- a/mobile/lib/features/channels/me/me_chat_draft.dart +++ b/mobile/lib/features/channels/me/me_chat_draft.dart @@ -107,7 +107,11 @@ class _MeChatDraft extends HookConsumerWidget { saved?.name ?? startingMeChatTitle(content), recipient, saved?.project ?? project.value, - initialContent: saved?.initialMessage?.content ?? content, + // A saved creation resends exactly what it froze. Older builds saved + // named creations without a first message; those finish without one. + initialContent: saved == null + ? content + : saved.initialMessage?.content, initialMediaTags: mediaTags, ); if (context.mounted) { @@ -119,6 +123,11 @@ class _MeChatDraft extends HookConsumerWidget { composerBusy.value = false; pending.value = MeChatCreation.pending(sync); error.value = failure.toString().replaceFirst('Bad state: ', ''); + // Conflicting pending Solo edits block every chat creation until the + // user decides. The saved creation stays, so Retry finishes it. + if (failure is MeRecoveryConflict) { + await offerMeRecovery(context, sync); + } } // ComposeBar treats StateError as a community switch. Preserve the // actual failure and its draft through its ordinary error path. @@ -188,6 +197,7 @@ class _MeChatDraft extends HookConsumerWidget { scopeChanged ? 'Your account or community changed. Return to Solo to start a chat.' : pending.value?.initialMessage?.content ?? + pending.value?.name ?? 'What would you like to work on?', textAlign: TextAlign.center, style: context.textTheme.bodyMedium?.copyWith( @@ -212,7 +222,7 @@ class _MeChatDraft extends HookConsumerWidget { : () async { try { await send( - pending.value!.initialMessage!.content, + pending.value!.initialMessage?.content ?? '', const [], ); } catch (_) { diff --git a/mobile/lib/features/channels/me/me_page.dart b/mobile/lib/features/channels/me/me_page.dart index 261d7bb414..83ec9b0b4f 100644 --- a/mobile/lib/features/channels/me/me_page.dart +++ b/mobile/lib/features/channels/me/me_page.dart @@ -2,18 +2,13 @@ import 'dart:convert'; import 'package:buzz/shared/widgets/feedback_snackbar.dart'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:uuid/uuid.dart'; import '../../../shared/profile/user_cache_provider.dart'; -import '../../../shared/mentions/agent_identity_provider.dart'; -import '../../../shared/animated_avatar.dart'; -import '../../../shared/widgets/app_list.dart'; import '../../../shared/widgets/concentric_sheet_surface.dart'; import '../../../shared/widgets/bee_refresh_indicator.dart'; -import 'me_assistant_list.dart'; import 'me_section.dart'; import 'me_working_provider.dart'; import 'solo_working_dots.dart'; @@ -48,7 +43,6 @@ part 'me_page_helpers.dart'; part 'me_empty_state.dart'; part 'new_me_chat.dart'; part 'me_chat_draft.dart'; -part 'native_new_me_chat.dart'; /// Personal chats and projects shared with the desktop Me workspace. class MePage extends HookConsumerWidget { @@ -127,19 +121,7 @@ class MePage extends HookConsumerWidget { }); final VoidCallback? newChat = busy.value || !groups.hasValue ? null - : () => perform(() async { - final result = await _showNewMeChat(context, ref); - if (result == null || !context.mounted) return; - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ChannelDetailPage( - channel: result.$1, - personalChat: true, - initialAssistant: result.$2, - ), - ), - ); - }); + : () => perform(() => _openNewMeChat(context, ref)); useEffect(() { var active = true; diff --git a/mobile/lib/features/channels/me/native_new_me_chat.dart b/mobile/lib/features/channels/me/native_new_me_chat.dart deleted file mode 100644 index 1ea35820dc..0000000000 --- a/mobile/lib/features/channels/me/native_new_me_chat.dart +++ /dev/null @@ -1,290 +0,0 @@ -part of 'me_page.dart'; - -Future<(Channel, String)?> _showNewMeChat( - BuildContext context, - WidgetRef ref, -) async { - final initialSync = ref.read(meSyncProvider); - final unfinished = MeChatCreation.pending(initialSync); - if (unfinished != null) { - final resume = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Unfinished chat'), - content: Text( - 'Resume “${unfinished.name}”, or discard its setup to start another chat. Any channel and messages already created are kept in Home or Solo.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Later'), - ), - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Discard setup'), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Resume'), - ), - ], - ), - ); - if (resume == null || !context.mounted) return null; - if (!resume) { - await unfinished.discard(initialSync); - ref.invalidate(channelsProvider); - } - } - if (!context.mounted) return null; - final pendingDraft = MeChatCreation.pending(ref.read(meSyncProvider)); - if (pendingDraft == null || pendingDraft.initialMessage != null) { - await Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => const _MeChatDraft())); - return null; - } - if (defaultTargetPlatform == TargetPlatform.iOS) { - final sync = ref.read(meSyncProvider); - final pending = MeChatCreation.pending(sync); - final id = pending?.id ?? const Uuid().v4(); - final messageMode = pending == null || pending.initialMessage != null; - String name = pending?.initialMessage?.content ?? 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) { - sync.check(); - final directory = ref.read(meAssistantsProvider).asData?.value; - final directoryRequest = ref.read(meAssistantsProvider).hasError - ? ref.refresh(meAssistantsProvider.future) - : ref.read(meAssistantsProvider.future); - final presentationId = const Uuid().v4(); - var active = true; - if (!context.mounted) return null; - sync.check(); - if (!context.mounted) return null; - final theme = utilitySurfaceThemeData(Theme.of(context)); - Map? draft; - ProviderSubscription>? bestieSubscription; - try { - final presentation = const MethodChannel('buzz/profile_text_editor') - .invokeMethod>('presentChat', { - 'title': 'New chat', - 'presentationId': presentationId, - 'assistantsLoading': directory == null, - 'initialValue': name, - 'placeholder': messageMode ? 'Message' : 'Chat name', - 'multiline': messageMode, - 'messageMode': messageMode, - 'brightness': theme.brightness.name, - 'pageBackgroundArgb': theme.colorScheme.surface.toARGB32(), - 'containerBackgroundArgb': theme - .colorScheme - .surfaceContainerHighest - .toARGB32(), - 'containerCornerRadius': Radii.container, - 'selectedAssistant': - assistant ?? _availableBestie(ref, directory ?? []), - if (retry && assistant != null) - 'savedAssistant': { - 'id': assistant, - 'name': - directory - ?.where((a) => a.pubkey == assistant) - .firstOrNull - ?.displayName ?? - ref.read(userCacheProvider)[assistant]?.label ?? - assistant, - 'avatarUrl': - ref.read(userCacheProvider)[assistant]?.avatarUrl ?? '', - }, - '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 ?? []), - }); - // Send updates only to this presentation; late results cannot populate - // a reopened sheet or a different community's editor. - Future loadAssistants() async { - try { - final agents = await directoryRequest; - if (!active || !context.mounted || sync.disposed) return; - await const MethodChannel( - 'buzz/profile_text_editor', - ).invokeMethod('updateChatAssistants', { - 'presentationId': presentationId, - 'assistants': _nativeMeAssistants(agents), - 'defaultAssistant': _availableBestie(ref, agents), - }); - } catch (_) { - if (!active || !context.mounted || sync.disposed) return; - try { - await const MethodChannel( - 'buzz/profile_text_editor', - ).invokeMethod('updateChatAssistants', { - 'presentationId': presentationId, - 'assistants': [], - 'assistantError': - 'Couldn’t load agents. Close this sheet and try again.', - }); - } on PlatformException { - // The native editor may already be dismissed. - } on MissingPluginException { - // Flutter fallback has no native editor. - } - } - } - - loadAssistants(); - bestieSubscription = ref.listenManual(defaultAgentProvider, (_, next) { - if (next.hasValue) loadAssistants(); - }); - draft = await presentation; - } on MissingPluginException { - break; - } finally { - active = false; - bestieSubscription?.close(); - } - 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, - messageMode ? startingMeChatTitle(name) : name, - assistant, - project, - initialContent: messageMode ? name : null, - ); - return (created, assistant); - } catch (failure) { - if (failure is MeRecoveryConflict && context.mounted) { - await offerMeRecovery(context, sync); - } - error = - '${failure.toString().replaceFirst('Bad state: ', '')}\nRetry continues this same chat.'; - } - } - } - if (!context.mounted) return null; - return showBuzzModalBottomSheet<(Channel, String)>( - context: context, - title: 'New chat', - isScrollControlled: true, - requestFocus: true, - builder: (_) => const _NewMeChat(), - ); -} - -Future _createMeChat( - WidgetRef ref, - MeSync sync, - String channelId, - String name, - String assistant, - String? project, { - String? initialContent, - List> initialMediaTags = const [], -}) async { - sync = _currentMeSync(ref, sync); - final pending = MeChatCreation.pending(sync); - final initialMessage = initialContent == null - ? null - : pending?.initialMessage ?? - NostrEvent.fromJson( - signNostrEvent( - kind: EventKind.streamMessage, - content: initialContent, - tags: [ - ['h', channelId], - ['p', assistant], - ...initialMediaTags, - ], - secretKey: sync.privateHex, - ).toMap(), - ); - final intent = MeChatCreation( - channelId, - name, - assistant, - project: project, - initialMessage: initialMessage, - ); - await intent.save(sync); - final actions = ref.read(channelActionsProvider); - final preflight = await Future.wait([ - isOwnedMeAssistant(sync, assistant), - sync.head(), - actions.findJoinedChannel(channelId), - ]); - final owned = preflight[0] as bool; - sync.check(); - if (!owned) { - throw StateError('This agent is no longer available.'); - } - final head = preflight[1] as NostrEvent?; - (head == null ? const MeGroups() : sync.decode(head)) - .place(channelId, group: project) - .encode(sync.community); - return intent.complete( - sync, - findChannel: () async => preflight[2] as Channel?, - createChannel: () => actions.createChannel( - channelId: channelId, - name: name, - channelType: 'stream', - visibility: 'private', - labels: const ['buzz.solo'], - waitForChannelList: false, - ), - hasAgent: () async { - ref.invalidate(channelMembersProvider(channelId)); - final members = await ref.read(channelMembersProvider(channelId).future); - return members.any((m) => m.pubkey == assistant); - }, - addAgent: () => - actions.addMembers(channelId: channelId, pubkeys: [assistant]), - isPlaced: () async { - final latest = await sync.head(); - 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, group: project)), - ); -} - -List> _nativeMeAssistants( - List agents, -) => [ - for (final agent in agents) - { - 'id': agent.pubkey, - 'name': agent.displayName ?? 'Agent', - 'artworkScale': usesInsetAgentArtwork(agent.avatarUrl) - ? '$animatedAvatarArtworkScale' - : '1', - 'avatarUrl': agent.avatarUrl ?? '', - }, -]; - -String? _availableBestie(WidgetRef ref, List choices) { - final bestie = ref.read(defaultAgentProvider).value; - return choices.any((agent) => agent.pubkey == bestie) ? bestie : null; -} diff --git a/mobile/lib/features/channels/me/new_me_chat.dart b/mobile/lib/features/channels/me/new_me_chat.dart index 3afb454f3d..809cca6c46 100644 --- a/mobile/lib/features/channels/me/new_me_chat.dart +++ b/mobile/lib/features/channels/me/new_me_chat.dart @@ -1,227 +1,122 @@ part of 'me_page.dart'; -class _NewMeChat extends HookConsumerWidget { - const _NewMeChat(); - @override - Widget build(BuildContext context, WidgetRef ref) { - final sync = ref.watch(meSyncProvider); - final pending = useMemoized(() => MeChatCreation.pending(sync)); - final messageMode = pending == null || pending.initialMessage != null; - final name = useTextEditingController( - text: pending?.initialMessage?.content ?? pending?.name ?? '', - ); - final selected = useState(pending?.agent); - final manuallySelected = useState(false); - 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); - final busy = useState(false); - final error = useState( - pending == null ? null : 'Resume your unfinished chat.', - ); - final directory = ref.watch(meAssistantsProvider); - final originalScope = useMemoized(() => (sync.community, sync.pubkey)); - if (originalScope != (sync.community, sync.pubkey)) { - return const Padding( - padding: EdgeInsets.all(Grid.gutter), - child: Text( - 'Your account or community changed. Return to Solo to start a chat.', +/// Opens the New chat draft, first offering to resume or discard a creation +/// that was interrupted before it finished. +Future _openNewMeChat(BuildContext context, WidgetRef ref) async { + final initialSync = ref.read(meSyncProvider); + final unfinished = MeChatCreation.pending(initialSync); + if (unfinished != null) { + final resume = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Unfinished chat'), + content: Text( + 'Resume “${unfinished.name}”, or discard its setup to start another chat. Any channel and messages already created are kept in Home or Solo.', ), - ); - } - final choices = directory.asData?.value ?? []; - final bestie = ref.watch(defaultAgentProvider).value; - useEffect(() { - if (!manuallySelected.value && frozenAgent.value == null) { - selected.value = choices.any((a) => a.pubkey == bestie) ? bestie : null; - } - return null; - }, [bestie, choices]); - final profile = ref.watch(userCacheProvider)[selected.value]; - final chosen = - choices.where((agent) => agent.pubkey == selected.value).firstOrNull ?? - (frozenAgent.value == null - ? null - : AgentDirectoryEntry( - pubkey: frozenAgent.value!, - displayName: profile?.label ?? frozenAgent.value!, - )); - return SingleChildScrollView( - padding: EdgeInsets.fromLTRB( - Grid.gutter, - Grid.xxs, - Grid.gutter, - MediaQuery.viewInsetsOf(context).bottom + Grid.xs, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextField( - key: const ValueKey('new-session-name'), - controller: name, - autofocus: true, - textCapitalization: TextCapitalization.sentences, - enabled: !busy.value && frozenName.value == null, - maxLength: messageMode ? null : 120, - minLines: messageMode ? 3 : 1, - maxLines: messageMode ? 6 : 1, - decoration: InputDecoration( - labelText: messageMode ? 'Message' : 'Chat name', - hintText: messageMode ? 'What would you like to work on?' : null, - ), - onChanged: (_) => error.value = null, + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Later'), ), - 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, + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Discard setup'), ), - const SizedBox(height: Grid.xs), - AppListRowRaw( - key: const ValueKey('new-session-assistant'), - leading: chosen == null - ? const Icon(BuzzIcons.plus) - : AvatarImage( - imageUrl: profile?.avatarUrl, - radius: 20, - isAgent: true, - backgroundColor: context.controlSurface, - fallback: Text( - chosen.displayName?.trim().isNotEmpty == true - ? chosen.displayName!.trim()[0].toUpperCase() - : profile?.initial ?? 'A', - ), - ), - title: Text(chosen?.displayName ?? 'Choose an agent'), - trailing: const Icon(BuzzIcons.chevronRight, size: 20), - onTap: busy.value || frozenName.value != null - ? null - : () async { - FocusScope.of(context).unfocus(); - if (!context.mounted) return; - final picked = await showBuzzModalBottomSheet( - context: context, - title: 'Agent', - isScrollControlled: true, - showDragHandle: true, - builder: (_) => MeAssistantList( - choices: choices, - selected: selected.value, - ), - ); - if (context.mounted && picked != null) { - manuallySelected.value = true; - selected.value = picked; - } - }, - ), - if (directory.isLoading) const LinearProgressIndicator(), - if (directory.hasError) - TextButton( - onPressed: () { - ref.invalidate(meAssistantsProvider); - }, - child: const Text('Reload agents'), - ), - if (!directory.isLoading && !directory.hasError && choices.isEmpty) - const Padding( - padding: EdgeInsets.symmetric(vertical: Grid.gutter), - child: Text( - 'No agents are available for this account. Connect an agent on desktop, then refresh.', - ), - ), - if (error.value != null) - Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.gutter), - child: Text(error.value!), - ), - const SizedBox(height: Grid.gutter), - FilledButton( - onPressed: busy.value - ? null - : () async { - if (name.text.trim().isEmpty || selected.value == null) { - error.value = messageMode - ? 'Enter a message and choose an agent.' - : 'Enter a name and choose an agent.'; - return; - } - if (messageMode && - utf8.encode(name.text.trim()).length > 8192) { - error.value = 'Message is too long. Keep it under 8 KB.'; - return; - } - busy.value = true; - error.value = null; - try { - frozenName.value ??= messageMode - ? startingMeChatTitle(name.text) - : name.text.trim(); - frozenAgent.value ??= selected.value; - final created = await _createMeChat( - ref, - sync, - channelId, - frozenName.value!, - frozenAgent.value!, - project.value, - initialContent: messageMode ? name.text.trim() : null, - ); - if (context.mounted) { - Navigator.of( - context, - ).pop((created, frozenAgent.value!)); - } - } catch (e) { - if (e is MeRecoveryConflict && context.mounted) { - await offerMeRecovery(context, sync); - } - if (context.mounted) { - error.value = - '${e.toString().replaceFirst('Bad state: ', '')}\nRetry continues this same chat.'; - } - } finally { - if (context.mounted) busy.value = false; - } - }, - child: Text( - busy.value - ? 'Creating chat…' - : frozenName.value != null - ? 'Retry' - : messageMode - ? 'Send' - : 'Create chat', - ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Resume'), ), ], ), ); + if (resume == null || !context.mounted) return; + if (!resume) { + await unfinished.discard(initialSync); + ref.invalidate(channelsProvider); + } } + if (!context.mounted) return; + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const _MeChatDraft())); +} + +Future _createMeChat( + WidgetRef ref, + MeSync sync, + String channelId, + String name, + String assistant, + String? project, { + String? initialContent, + List> initialMediaTags = const [], +}) async { + sync = _currentMeSync(ref, sync); + final pending = MeChatCreation.pending(sync); + final initialMessage = initialContent == null + ? null + : pending?.initialMessage ?? + NostrEvent.fromJson( + signNostrEvent( + kind: EventKind.streamMessage, + content: initialContent, + tags: [ + ['h', channelId], + ['p', assistant], + ...initialMediaTags, + ], + secretKey: sync.privateHex, + ).toMap(), + ); + final intent = MeChatCreation( + channelId, + name, + assistant, + project: project, + initialMessage: initialMessage, + ); + await intent.save(sync); + final actions = ref.read(channelActionsProvider); + final preflight = await Future.wait([ + isOwnedMeAssistant(sync, assistant), + sync.head(), + actions.findJoinedChannel(channelId), + ]); + final owned = preflight[0] as bool; + sync.check(); + if (!owned) { + throw StateError('This agent is no longer available.'); + } + final head = preflight[1] as NostrEvent?; + (head == null ? const MeGroups() : sync.decode(head)) + .place(channelId, group: project) + .encode(sync.community); + return intent.complete( + sync, + findChannel: () async => preflight[2] as Channel?, + createChannel: () => actions.createChannel( + channelId: channelId, + name: name, + channelType: 'stream', + visibility: 'private', + labels: const ['buzz.solo'], + waitForChannelList: false, + ), + hasAgent: () async { + ref.invalidate(channelMembersProvider(channelId)); + final members = await ref.read(channelMembersProvider(channelId).future); + return members.any((m) => m.pubkey == assistant); + }, + addAgent: () => + actions.addMembers(channelId: channelId, pubkeys: [assistant]), + isPlaced: () async { + final latest = await sync.head(); + 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, group: project)), + ); } diff --git a/mobile/test/features/channels/me/me_page_test.dart b/mobile/test/features/channels/me/me_page_test.dart index 47537b33da..c67d340641 100644 --- a/mobile/test/features/channels/me/me_page_test.dart +++ b/mobile/test/features/channels/me/me_page_test.dart @@ -365,99 +365,111 @@ void main() { }, ); - for (final resume in [true]) { - testWidgets('iOS new chat recovers a durable creation: resume=$resume', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - const channel = MethodChannel('buzz/profile_text_editor'); - final calls = []; - var directoryLoads = 0; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( - call, - ) async { - calls.add(call); - return null; - }); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + for (final resume in [true, false]) { + testWidgets( + 'new chat resumes a named creation in the draft: resume=$resume', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('buzz/profile_text_editor'); + final calls = []; + var directoryLoads = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( channel, - null, - ), - ); - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); - final sync = MeSync( - RelaySessionNotifier(), - RelayConfig( - baseUrl: 'https://relay.test', - nsec: nostr.Nip19.encode( - prefix: nostr.Nip19Prefix.nsec, - data: - '0000000000000000000000000000000000000000000000000000000000000001', + (call) async { + calls.add(call); + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, ), - ), - prefs: prefs, - ); - await const MeChatCreation( - 'same-id', - 'Unfinished chat', - 'agent', - project: 'mobile', - ).save(sync); - final restarted = MeSync( - RelaySessionNotifier(), - sync.config, - prefs: prefs, - ); - await tester.pumpWidget( - ProviderScope( - overrides: [ - defaultAgentProvider.overrideWith((ref) => Stream.value(null)), - meGroupsProvider.overrideWith( - (ref) => Stream.value(const MeGroups()), + ); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final sync = MeSync( + RelaySessionNotifier(), + RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: + '0000000000000000000000000000000000000000000000000000000000000001', ), - meSyncProvider.overrideWithValue(restarted), - channelsProvider.overrideWith(FakeChannels.new), - meAssistantsProvider.overrideWith((ref) async { - directoryLoads++; - return []; - }), - ], - child: MaterialApp(theme: AppTheme.light(), home: const MePage()), - ), - ); - await tester.pumpAndSettle(); + ), + prefs: prefs, + ); + await const MeChatCreation( + 'same-id', + 'Unfinished chat', + 'agent', + project: 'mobile', + ).save(sync); + final restarted = MeSync( + RelaySessionNotifier(), + sync.config, + prefs: prefs, + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + agentDirectoryProvider.overrideWith((ref) async => []), + agentOwnersProvider.overrideWith((ref) async => {}), + customEmojiListProvider.overrideWithValue([]), + defaultAgentProvider.overrideWith((ref) => Stream.value(null)), + meGroupsProvider.overrideWith( + (ref) => Stream.value(const MeGroups()), + ), + meSyncProvider.overrideWithValue(restarted), + channelsProvider.overrideWith(FakeChannels.new), + meAssistantsProvider.overrideWith((ref) async { + directoryLoads++; + return []; + }), + ], + child: MaterialApp(theme: AppTheme.light(), home: const MePage()), + ), + ); + await tester.pumpAndSettle(); - final bar = tester.widget(find.byType(FrostedAppBar)); - bar.nativeActions! - .firstWhere((action) => action.label == 'New chat') - .onPressed!(); - await tester.pumpAndSettle(); - await tester.tap(find.text(resume ? 'Resume' : 'Discard setup')); - await tester.pumpAndSettle(); - expect(directoryLoads, 1); - expect(calls, hasLength(1)); - expect(calls.single.method, 'presentChat'); - final args = calls.single.arguments as Map; - 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( - MeChatCreation.pending(restarted)?.id, - resume ? 'same-id' : isNull, - ); - expect(args['placeholder'], resume ? 'Chat name' : 'Message'); - expect(args['assistants'], isEmpty); - expect(args['containerCornerRadius'], Radii.container); - expect(find.byType(BottomSheet), findsNothing); - await tester.pumpWidget(const SizedBox.shrink()); - debugDefaultTargetPlatformOverride = null; - }); + final bar = tester.widget(find.byType(FrostedAppBar)); + bar.nativeActions! + .firstWhere((action) => action.label == 'New chat') + .onPressed!(); + await tester.pumpAndSettle(); + await tester.tap(find.text(resume ? 'Resume' : 'Discard setup')); + await tester.pumpAndSettle(); + expect(directoryLoads, 1); + expect(calls, isEmpty, reason: 'No native New chat sheet remains'); + expect(find.byType(BottomSheet), findsNothing); + // A resumed creation finishes with its saved name and agent; a + // discarded one leaves a fresh draft. + expect(find.text('Retry'), resume ? findsOneWidget : findsNothing); + expect(find.byType(ComposeBar), resume ? findsNothing : findsOneWidget); + expect( + find.text('Unfinished chat'), + resume ? findsOneWidget : findsNothing, + ); + expect( + MeChatCreation.pending(restarted)?.id, + resume ? 'same-id' : isNull, + ); + final agent = tester + .widget(find.byType(FrostedAppBar).last) + .nativeActions! + .last; + expect( + agent.onPressed, + resume ? isNull : isNotNull, + reason: 'A resumed creation keeps its saved agent', + ); + await tester.pumpWidget(const SizedBox.shrink()); + debugDefaultTargetPlatformOverride = null; + }, + ); } testWidgets('unavailable preferences show retry instead of empty state', ( 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 8081b65a13..0ddc938b89 100644 --- a/mobile/test/features/channels/me/solo_creation_tags_test.dart +++ b/mobile/test/features/channels/me/solo_creation_tags_test.dart @@ -12,12 +12,9 @@ import 'package:buzz/shared/me/me_assistants.dart'; import 'package:buzz/shared/me/me_chat_creation.dart'; import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/me/me_groups.dart'; +import 'package:buzz/shared/crypto/nip44.dart'; import 'package:buzz/shared/me/me_sync.dart'; import 'package:buzz/shared/relay/relay.dart'; -import 'package:buzz/shared/profile/user_cache_provider.dart'; -import 'package:buzz/shared/profile/user_profile.dart'; -import 'package:buzz/shared/widgets/app_list.dart'; -import 'package:flutter/foundation.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/frosted_app_bar.dart'; import 'package:flutter/material.dart'; @@ -38,6 +35,7 @@ class _Relay extends RelaySessionNotifier { final submitted = []; _Relay(this.profile, this.existing); final List existing; + var groups = []; @override Future> queryRelay( @@ -49,7 +47,10 @@ class _Relay extends RelaySessionNotifier { Future> fetchHistory( NostrFilter filter, { Duration timeout = const Duration(seconds: 8), - }) async => existing.where((e) => filter.kinds.contains(e.kind)).toList(); + }) async => [ + ...existing, + ...groups, + ].where((e) => filter.kinds.contains(e.kind)).toList(); @override Future publish( @@ -81,7 +82,6 @@ void main() { null, ), ); - final flutterSheet = defaultTargetPlatform == TargetPlatform.android; SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); final owner = nostr.Keys.generate(), agent = nostr.Keys.generate(); @@ -173,33 +173,22 @@ void main() { } }); final directory = Completer>(); - if (!recoverExisting) { - directory.complete([ + // A recovered creation keeps its saved agent even when discovery no + // longer lists it. + directory.complete([ + if (!recoverExisting) AgentDirectoryEntry( pubkey: agent.public, displayName: 'Owned agent', ), - ]); - } else if (flutterSheet) { - directory.complete([]); - } - final draft = Completer>(); - String? presentationId; - Map? agentUpdate; - Object? savedAssistant; - var forms = 0; + ]); + var agentAdded = false; + final bridgeCalls = []; tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(bridge, ( call, ) async { - if (call.method == 'updateChatAssistants') { - agentUpdate = call.arguments as Map; - return null; - } - if (++forms > 1) return null; - final arguments = call.arguments as Map; - presentationId = arguments['presentationId'] as String; - savedAssistant = arguments['savedAssistant']; - return draft.future; + bridgeCalls.add(call); + return null; }); addTearDown( () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( @@ -224,7 +213,16 @@ void main() { ), meAssistantsProvider.overrideWith((ref) => directory.future), channelsProvider.overrideWith(_EmptyChannels.new), - channelMembersProvider(channelId).overrideWith((ref) async => []), + channelMembersProvider(channelId).overrideWith( + (ref) async => [ + if (agentAdded) + ChannelMember( + pubkey: agent.public, + role: 'bot', + joinedAt: DateTime(2026), + ), + ], + ), channelActionsProvider.overrideWith( (ref) => ChannelActions( ref: ref, @@ -254,57 +252,24 @@ void main() { await tester.tap(find.text('Resume')); await tester.pumpAndSettle(); } - if (!recoverExisting) { + expect(bridgeCalls, isEmpty, reason: 'No native New chat sheet'); + if (recoverExisting) { + expect(find.byType(ComposeBar), findsNothing); + expect( + tester + .widget(find.byType(FrostedAppBar).last) + .nativeActions! + .last + .onPressed, + isNull, + reason: 'The saved agent stays locked', + ); + await tester.tap(find.text('Retry')); + } else { final composer = tester.widget(find.byType(ComposeBar)); unawaited( composer.onSend('Mobile Solo', const []).catchError((_) {}), ); - } else if (flutterSheet) { - final row = find.byKey(const ValueKey('new-session-assistant')); - final semantics = tester.ensureSemantics(); - await tester.pump(); - expect(find.text('Choose an agent'), findsNothing); - expect(find.text(agent.public), findsOneWidget); - expect(find.bySemanticsLabel(agent.public), findsOneWidget); - expect(tester.widget(row).onTap, isNull); - final container = ProviderScope.containerOf(tester.element(row)); - container - .read(userCacheProvider.notifier) - .put( - UserProfile(pubkey: agent.public, displayName: 'Saved agent'), - ); - await tester.pumpAndSettle(); - expect(find.text('Saved agent'), findsOneWidget); - expect(find.bySemanticsLabel('Saved agent'), findsOneWidget); - expect(tester.widget(row).onTap, isNull); - semantics.dispose(); - await tester.tap(find.text('Retry')); - } else { - 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', - ), - ]); - await tester.pumpAndSettle(); - expect(agentUpdate?['presentationId'], presentationId); - expect( - (agentUpdate?['assistants'] as List).single['id'], - agent.public, - ); - draft.complete({'name': 'Mobile Solo', 'assistant': agent.public}); } for ( var attempts = 0; @@ -325,8 +290,87 @@ void main() { 9000, reason: 'Recovery continues with member setup', ); - expect(MeChatCreation.pending(sync)?.id, channelId); + final pending = MeChatCreation.pending(sync)!; + expect(pending.id, channelId); + expect(pending.name, 'Mobile Solo'); + expect( + pending.initialMessage, + isNull, + reason: 'A named creation from an older build finishes as saved', + ); expect(command.tags, contains(equals(['p', agent.public]))); + + // A conflicting pending Solo edit blocks placement. Retry offers the + // recovery choice and keeps the saved creation for the next Retry. + agentAdded = true; + relay.submitted.clear(); + MeGroups organization(String group) => MeGroups( + groups: [ + for (final id in ['original', 'phone', 'desktop']) + {'id': id, 'name': id, 'defaultTemplateId': ''}, + ], + assignments: {channelId: group}, + channels: const [channelId], + ); + NostrEvent signed(String group, int timestamp) => NostrEvent.fromJson( + nostr.Event.from( + kind: 30078, + content: nip44Encrypt( + sync.key, + organization(group).encode(sync.community), + ), + secretKey: sync.privateHex, + createdAt: timestamp, + tags: [ + ['d', MeGroups.coordinate(sync.community)], + ['t', MeGroups.tag], + ], + verify: false, + ).toMap(), + ); + await prefs.setString( + sync.pendingKey, + jsonEncode({ + 'base': signed('original', 1).toJson(), + 'event': signed('phone', 2).toJson(), + }), + ); + relay.groups = [signed('desktop', 3)]; + await tester.tap(find.text('Retry')); + for ( + var attempts = 0; + attempts < 100 && + find.text('Conflicting Solo changes').evaluate().isEmpty; + attempts++ + ) { + await tester.pumpAndSettle(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50)), + ); + } + expect(find.text('Conflicting Solo changes'), findsOneWidget); + expect(relay.submitted, isEmpty); + await tester.tap(find.text('Discard phone change')); + await tester.pumpAndSettle(); + expect(prefs.getString(sync.pendingKey), isNull); + expect(MeChatCreation.pending(sync)?.id, channelId); + await tester.tap(find.text('Retry')); + for ( + var attempts = 0; + attempts < 100 && MeChatCreation.pending(sync) != null; + attempts++ + ) { + await tester.pumpAndSettle(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50)), + ); + } + await tester.pumpAndSettle(); + // Desktop already lists the chat, so Retry finishes the creation + // without another organization write. + expect(MeChatCreation.pending(sync), isNull); + expect(relay.submitted, isEmpty); + expect(find.text('Retry'), findsNothing); } else { final pending = MeChatCreation.pending(sync)!; expect(pending.name, 'Mobile Solo'); diff --git a/scripts/test-mobile-me-agent-accessibility.sh b/scripts/test-mobile-me-agent-accessibility.sh deleted file mode 100755 index d1477b3373..0000000000 --- a/scripts/test-mobile-me-agent-accessibility.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash -# Exercise the production UIKit Me agent row on a booted iOS simulator. -set -euo pipefail -repo_root=$(cd "$(dirname "$0")/.." && pwd) -device=${1:?Pass a booted iOS simulator UDID} -flutter_root=$(sed -n 's/^FLUTTER_ROOT=//p' "$repo_root/mobile/ios/Flutter/Generated.xcconfig") -frameworks="$flutter_root/bin/cache/artifacts/engine/ios/Flutter.xcframework/ios-arm64_x86_64-simulator" -fixture=$(mktemp -d) -trap 'rm -r "$fixture"' EXIT -# Compile in one file to exercise the private production controller directly. -cat "$repo_root/mobile/ios/Runner/MeAvatarLoader.swift" \ - "$repo_root/mobile/ios/Runner/NavigationTopEdge.swift" \ - "$repo_root/mobile/ios/Runner/NativeProfileTextEditor.swift" \ - "$repo_root/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift" > "$fixture/test.swift" -xcrun --sdk iphonesimulator swiftc -parse-as-library \ - -sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" \ - -target "$(uname -m)-apple-ios16.0-simulator" \ - -F "$frameworks" -framework Flutter \ - -Xlinker -rpath -Xlinker "$frameworks" \ - "$fixture/test.swift" \ - -o "$fixture/me-agent-tests" -xcrun simctl spawn "$device" "$fixture/me-agent-tests"