mirror of
https://github.com/block/buzz.git
synced 2026-11-02 23:41:08 +00:00
Mobile New chat now opens directly with the synced Bestie selected. The first send creates and names the chat; leaving an empty draft creates nothing. New and existing Solo chats can select or clear a workspace from the header popover, with a checkmark for the active choice and a No Workspace option. Workspace and agent controls have separate iOS glass surfaces, Bestie stays at the far right, and Back uses a chevron. The agent sheet fits its list, with scrolling for long lists. Workspace creation stays in the Home New menu. The New menu groups Solo above Team. Android expands across the full control row; iOS keeps its native menu while Search and Bestie merge into New through Liquid Glass, with synchronized icon motion and the residual glass fading away. Empty workspaces use a compact, muted message. Validation: 4,055 mobile tests passed (34 skipped), static analysis passed, and native menu checks passed. Earlier physical-device previews and the iPhone New-menu animation were visually approved before this consolidation. This PR consolidates the controls previously developed in #8891 into the existing draft/first-send implementation. The New menu animations, attachment handling, and durable retries remain in this PR. The combined controls have widget interaction coverage; the combined build has not yet had a new physical-device walkthrough. Current combined controls below are Android Flutter widget renders with fixture data, not device screenshots. Images are GitHub attachments only. | New conversation | Workspace popover | Compact agent sheet | | --- | --- | --- | |  |  |  | Empty workspace widget preview (unchanged):  --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
233 lines
13 KiB
Swift
233 lines
13 KiB
Swift
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)
|
|
}
|
|
}
|