Mobile: one New chat screen (#9079)

Mobile had two New chat screens.
[#8884](https://github.com/block/buzz/pull/8884) made the Flutter draft
page (first send creates and names the chat) the New chat screen, but
kept the older create form (a native iOS sheet, with a Flutter bottom
sheet on Android) for one case: resuming a creation that an older build
had saved with a chat name and no first message. Nothing creates that
state anymore, so the old form only appeared for users with an
interrupted creation from before that change.

This removes the old form. Every New chat, including that resume case,
now opens the draft page:

- **New chat**: unchanged.
- **Resuming an interrupted creation**: after the existing Resume /
Discard setup / Later prompt, the draft page shows the saved chat with
its agent and workspace locked and a Retry button, the same choices the
old sheet offered when it locked its fields. A creation saved with a
first message resumes exactly as before. One saved without a message
finishes without one, because a saved creation can't change after its
first relay write.

The iOS editor's chat-only code (`presentChat`, `updateChatAssistants`,
the agent list, and its avatar loader) goes too. The same editor still
handles profile fields, channel details and workspace names. The native
test and CI step that covered only the removed chat form are removed.

### Screenshots

Resuming an interrupted creation saved without a first message:

| Before: old native sheet (removed) | After: draft page (kept) |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/5d0a502e92fc3dfeffbe4fa95d63ba8f73d1e612/pr-9079--01-before-resume-native-sheet.png"
width="300"> | <img
src="https://raw.githubusercontent.com/block/buzz/5d0a502e92fc3dfeffbe4fa95d63ba8f73d1e612/pr-9079--02-after-resume-draft-page.png"
width="300"> |

A brand new chat already opened the draft page and is unchanged:

<img
src="https://raw.githubusercontent.com/block/buzz/5d0a502e92fc3dfeffbe4fa95d63ba8f73d1e612/pr-9079--03-new-chat-draft-page.png"
width="300">

### Validation

- Changed tests: `me_page_test` checks that Resume and Discard setup
both open the draft page and that no native sheet is requested.
`solo_creation_tags_test` checks that recovering an interrupted named
creation through the draft page's Retry continues with member setup,
keeps the saved name and agent, and adds no first message.
- Full mobile Flutter suite (4,431 passed) and `flutter analyze`
(clean). iOS debug simulator build succeeds.
- Automated simulator run (iPhone 17 Pro, iOS 27, signed-in account):
New chat opened the draft page, the agent picker selected an agent, and
the first send created the chat, which then appeared under Solo. New
workspace still opened the native editor, enabled Create once named, and
showed the Discard changes prompt on cancel. The recording isn't
attached here because it shows internal account content.
- Resuming an interrupted
pre-[#8884](https://github.com/block/buzz/pull/8884) creation can't be
produced by the current build. For the screenshots above, a
capture-only, uncommitted patch to a `main` build seeded one; the branch
build installed over it resumed it on the draft page. The widget tests
cover finishing it.
- `solo_creation_tags_test` also covers a conflicting pending Solo
organization edit during that Retry: the draft offers the Conflicting
Solo changes dialog, keeps the saved creation, and the next Retry
finishes it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


buzz-review-completed

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Brow
2026-11-04 16:34:05 -08:00
committed by GitHub
parent 237d794acb
commit e558f15faa
14 changed files with 375 additions and 1350 deletions

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -229,4 +229,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: ed10001903303a0ae660957e1d727012a3136046
COCOAPODS: 1.16.2
COCOAPODS: 1.17.0

View File

@@ -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 = "<group>"; };
BBAA00000000000000000002 /* HostedHpkeBackup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostedHpkeBackup.swift; sourceTree = "<group>"; };
DAAB00000000000000000002 /* NativeTabler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTabler.swift; sourceTree = "<group>"; };
4A71C01A2F40D00100A17E02 /* MeAvatarLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeAvatarLoader.swift; sourceTree = "<group>"; };
C0AF00000000000000000002 /* NativeConfirmationDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeConfirmationDialog.swift; sourceTree = "<group>"; };
C0AF00000000000000000022 /* NativePresenceMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePresenceMenu.swift; sourceTree = "<group>"; };
C0AF00000000000000000012 /* NativeAddMembersSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAddMembersSheet.swift; sourceTree = "<group>"; };
@@ -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 */,

View File

@@ -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
}

View File

@@ -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<NSURL, UIImage>()
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)
}
}

View File

@@ -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)
}
}

View File

@@ -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 (_) {

View File

@@ -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<void>(
builder: (_) => ChannelDetailPage(
channel: result.$1,
personalChat: true,
initialAssistant: result.$2,
),
),
);
});
: () => perform(() => _openNewMeChat(context, ref));
useEffect(() {
var active = true;

View File

@@ -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<bool>(
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<void>(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<Object?, Object?>? draft;
ProviderSubscription<AsyncValue<String?>>? bestieSubscription;
try {
final presentation = const MethodChannel('buzz/profile_text_editor')
.invokeMethod<Map<Object?, Object?>>('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<void> loadAssistants() async {
try {
final agents = await directoryRequest;
if (!active || !context.mounted || sync.disposed) return;
await const MethodChannel(
'buzz/profile_text_editor',
).invokeMethod<void>('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<void>('updateChatAssistants', {
'presentationId': presentationId,
'assistants': <Object>[],
'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<Channel> _createMeChat(
WidgetRef ref,
MeSync sync,
String channelId,
String name,
String assistant,
String? project, {
String? initialContent,
List<List<String>> 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<Object?>([
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<Channel>(
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<Map<String, String>> _nativeMeAssistants(
List<AgentDirectoryEntry> 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<AgentDirectoryEntry> choices) {
final bestie = ref.read(defaultAgentProvider).value;
return choices.any((agent) => agent.pubkey == bestie) ? bestie : null;
}

View File

@@ -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<String?>(pending?.agent);
final manuallySelected = useState(false);
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);
final busy = useState(false);
final error = useState<String?>(
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<void> _openNewMeChat(BuildContext context, WidgetRef ref) async {
final initialSync = ref.read(meSyncProvider);
final unfinished = MeChatCreation.pending(initialSync);
if (unfinished != null) {
final resume = await showDialog<bool>(
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<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,
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<String>(
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<void>(MaterialPageRoute(builder: (_) => const _MeChatDraft()));
}
Future<Channel> _createMeChat(
WidgetRef ref,
MeSync sync,
String channelId,
String name,
String assistant,
String? project, {
String? initialContent,
List<List<String>> 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<Object?>([
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<Channel>(
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)),
);
}

View File

@@ -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 = <MethodCall>[];
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 = <MethodCall>[];
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<FrostedAppBar>(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<FrostedAppBar>(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<FrostedAppBar>(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', (

View File

@@ -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 = <NostrEvent>[];
_Relay(this.profile, this.existing);
final List<NostrEvent> existing;
var groups = <NostrEvent>[];
@override
Future<List<NostrEvent>> queryRelay(
@@ -49,7 +47,10 @@ class _Relay extends RelaySessionNotifier {
Future<List<NostrEvent>> 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<NostrEvent> 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<List<AgentDirectoryEntry>>();
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<Map<String, String>>();
String? presentationId;
Map<Object?, Object?>? agentUpdate;
Object? savedAssistant;
var forms = 0;
]);
var agentAdded = false;
final bridgeCalls = <MethodCall>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(bridge, (
call,
) async {
if (call.method == 'updateChatAssistants') {
agentUpdate = call.arguments as Map<Object?, Object?>;
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<FrostedAppBar>(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<ComposeBar>(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<AppListRowRaw>(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<AppListRowRaw>(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<void>.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<void>.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');

View File

@@ -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"