ios: unify attachment controls and message typography

This commit is contained in:
2026-10-14 22:03:21 -07:00
parent a1c0264d36
commit 60a35509d4
11 changed files with 403 additions and 47 deletions
+6
View File
@@ -110,6 +110,12 @@ async def route(request):
push_available = True; auth_required = False
for i in range(body.get('count', 3)):
message(f'History message {i + 1}: local simulator fixture.')
elif action == 'presentation-samples':
# Same source messages in normal chat and Agent; loopback-only UI comparisons.
messages.clear(); cursor = 0; read_cursor = 0
transcript_comparison = True
for sample in body['messages']:
message(sample['content'], sample['role'])
elif action == 'transcript-comparison':
transcript_comparison = True
elif action == 'capability':
@@ -0,0 +1,110 @@
import XCTest
import SwiftUI
@testable import Sybil
private let originalBytes = Data([0, 255, 10, 13, 128, 42])
private let attachmentLimits = AgentAttachmentCapabilities(version: 1, maxCount: 8, maxTotalBytes: 33_554_432,
maxImageBytes: 6_291_456, maxFileBytes: 8_388_608, imageMimeTypes: ["image/png", "image/jpeg"],
maxImagePixels: 50_000_000, maxImageDimension: 16_384)
/// In-memory upload-only fixture. Every other path fails; no socket/provider IO.
private actor AttachmentTestClient: AgentAPIClienting {
var fail = false
var delay = Duration.milliseconds(1200)
func failNextUpload(delay: Duration) { fail = true; self.delay = delay }
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment {
try await Task.sleep(for: delay)
if fail {
fail = false
throw AgentAttachmentIssue(message: "Free storage or retry later.", code: "attachment_quota_exceeded", statusCode: 507)
}
return AgentAttachment(id: "synthetic-" + uploadID, kind: .file, filename: file.filename,
mimeType: file.mimeType, sizeBytes: file.data.count, sha256: file.sha256)
}
func agentDescriptor() throws -> AgentDescriptor { throw AgentAttachmentIssue.unavailable }
func agentMessages(after: String?, before: String?) throws -> AgentPage { throw AgentAttachmentIssue.unavailable }
func agentReadState() throws -> AgentReadState { throw AgentAttachmentIssue.unavailable }
func agentMarkRead(cursor: String) throws -> AgentReadState { throw AgentAttachmentIssue.unavailable }
func agentSubmit(content: String, requestID: String) throws -> AgentReceipt { throw AgentAttachmentIssue.unavailable }
func agentSocket(after: String) throws -> any AgentSocket { throw AgentAttachmentIssue.unavailable }
}
@MainActor private func attachmentEventually(_ predicate: () -> Bool) async -> Bool {
for _ in 0..<200 { if predicate() { return true }; try? await Task.sleep(for: .milliseconds(10)) }
return false
}
// Rendering the production composer against the in-memory upload fixture
// covers transient states without changing the shipping picker or contacting a provider.
@MainActor
final class AgentComposerPresentationTests: XCTestCase {
func testLoadingUploadingFailureRetryCapacityAndCapabilitySnapshots() async throws {
XCTAssertTrue(UserDefaults.standard.string(forKey: "sybil.ios.apiBaseURL") == "http://127.0.0.1:8799/api")
XCTAssertTrue(ProcessInfo.processInfo.environment["SYBIL_PUSH_FIXTURE"] == "1")
let room = SybilAgentRoom(), client = AttachmentTestClient()
let draft = room.attachmentDraft
draft.configure(client: client, storage: nil)
var limits = attachmentLimits
limits.maxCount = 1
draft.updateCapability(limits)
await client.failNextUpload(delay: .milliseconds(1200))
await capture(room, "composer-enabled-empty")
let id = try XCTUnwrap(draft.add(filename: "synthetic-original.bin") {
try await Task.sleep(for: .milliseconds(600))
return .init(filename: "synthetic-original.bin", mimeType: "application/octet-stream", data: originalBytes)
})
XCTAssertFalse(draft.canSend)
XCTAssertEqual(draft.items.first?.state, .loading)
await capture(room, "composer-loading-at-capacity-disabled")
let uploading = await attachmentEventually { draft.items.first?.state == .uploading }
XCTAssertTrue(uploading)
await capture(room, "composer-uploading-at-capacity-disabled")
let failed = await attachmentEventually { draft.items.first?.state == .failed }
XCTAssertTrue(failed); XCTAssertFalse(draft.canSend)
await capture(room, "composer-error-retry-disabled-send")
draft.retry(id)
let ready = await attachmentEventually { draft.items.first?.state == .ready }
XCTAssertTrue(ready); XCTAssertTrue(draft.canSend)
await capture(room, "composer-ready-enabled-send")
await capture(room, "composer-ready-accessibility3", size: .accessibility3)
draft.updateCapability(nil)
XCTAssertFalse(draft.canSend)
await capture(room, "composer-unavailable-retains-attachment")
draft.remove(id)
draft.caption = "Text still works"
XCTAssertTrue(draft.canSend)
await capture(room, "composer-text-only-capability")
draft.suspend()
}
private func capture(_ room: SybilAgentRoom, _ name: String, size: DynamicTypeSize = .large) async {
// ImageRenderer omits UIKit-backed fields/scroll views; host the real hierarchy.
let host = UIHostingController(rootView: AgentComposerPreview(room: room)
.padding(12).background(SybilTheme.background)
.environment(\.colorScheme, .dark).environment(\.dynamicTypeSize, size))
let fitting = host.sizeThatFits(in: CGSize(width: 414, height: 600))
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 414, height: max(120, fitting.height)))
window.rootViewController = host
window.makeKeyAndVisible()
defer { window.isHidden = true; window.rootViewController = nil }
host.view.frame = window.bounds
host.view.setNeedsLayout(); host.view.layoutIfNeeded()
try? await Task.sleep(for: .milliseconds(100))
let image = UIGraphicsImageRenderer(bounds: host.view.bounds).image { _ in
XCTAssertTrue(host.view.drawHierarchy(in: host.view.bounds, afterScreenUpdates: true))
}
let attachment = XCTAttachment(image: image)
attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
}
}
private struct AgentComposerPreview: View {
var room: SybilAgentRoom
@FocusState private var focused: Bool
var body: some View {
AgentAttachmentComposer(room: room) {
SybilPromptField(text: .constant(room.composer), focused: $focused, submit: {})
SybilPromptSendButton(canSend: room.attachmentDraft.canSend, submit: {})
}
}
}
@@ -0,0 +1,118 @@
import XCTest
import UIKit
/// Actual normal chat and Agent views, using only the loopback contract fixture.
@MainActor final class AgentPolishUITests: XCTestCase {
let app = XCUIApplication()
override func setUp() async throws {
continueAfterFailure = false
XCUIDevice.shared.orientation = .portrait
try await control(["action": "reset", "count": 0])
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"]
}
override func tearDown() async throws { app.terminate() }
private func control(_ body: [String: Any]) async throws {
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/control")!)
request.httpMethod = "POST"; request.httpBody = try JSONSerialization.data(withJSONObject: body)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (_, response) = try await URLSession.shared.data(for: request)
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
}
private func sidebar() {
if !app.buttons["agent-sidebar"].isHittable {
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 15))
app.buttons["Show sidebar"].tap()
}
}
private func openChat() {
sidebar()
let chat = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "Transcript fade comparison")).firstMatch
XCTAssertTrue(chat.waitForExistence(timeout: 8)); chat.tap()
XCTAssertTrue(app.buttons["chat-attach"].waitForExistence(timeout: 8))
}
private func openAgent() {
sidebar(); app.buttons["agent-sidebar"].tap()
XCTAssertTrue(app.buttons["agent-attach"].waitForExistence(timeout: 8))
}
private func capture(_ name: String) {
let shot = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
shot.name = name; shot.lifetime = .keepAlways; add(shot)
}
func testSharedAttachmentControlAndPickerFlows() async throws {
try await control(["action": "transcript-comparison"])
app.launch(); openChat()
let normal = app.buttons["chat-attach"]
XCTAssertEqual(normal.label, "Attach file"); XCTAssertTrue(normal.isEnabled)
let normalSize = normal.frame.size
let normalLabel = normal.label
XCTAssertGreaterThanOrEqual(normalSize.width, 44); XCTAssertGreaterThanOrEqual(normalSize.height, 44)
capture("polish-normal-attachment-control")
normal.tap(); app.buttons["Files"].tap()
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5)); app.buttons["Cancel"].firstMatch.tap()
normal.tap(); app.buttons["Photo Library"].tap()
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5)); app.buttons["Cancel"].firstMatch.tap()
openAgent()
let agent = app.buttons["agent-attach"]
XCTAssertEqual(agent.label, normalLabel); XCTAssertTrue(agent.isEnabled)
XCTAssertEqual(agent.frame.size.width, normalSize.width, accuracy: 1)
XCTAssertEqual(agent.frame.size.height, normalSize.height, accuracy: 1)
// Same row as the prompt, including the minimum VoiceOver/touch target.
XCTAssertEqual(agent.frame.maxY, app.buttons["agent-send"].frame.maxY, accuracy: 1)
capture("polish-agent-attachment-control")
agent.tap(); app.buttons["Files"].tap()
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5)); app.buttons["Cancel"].firstMatch.tap()
agent.tap(); app.buttons["Photo Library"].tap()
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5)); app.buttons["Cancel"].firstMatch.tap()
}
func testUserAssistantMultilineParagraphMetricsInBothWorkspaces() async throws {
try await compareMetrics(user: "User first\nUser second\n\nUser paragraph",
peer: "Peer first \nPeer second\n\nPeer paragraph", name: "multiline-paragraphs")
}
func testPastedParagraphMetricsInBothWorkspaces() async throws {
try await compareMetrics(user: "User first\r\nUser second\r\n \r\nUser paragraph",
peer: "Peer first \nPeer second\n\t\nPeer paragraph", name: "pasted-paragraphs")
}
private func compareMetrics(user: String, peer: String, name: String) async throws {
try await control(["action": "presentation-samples", "messages": [
["role": "user", "content": user], ["role": "assistant", "content": peer]]])
app.launch(); openChat()
let normal = try metrics()
capture("polish-normal-" + name)
openAgent()
let agent = try metrics()
XCTAssertEqual(normal.0, agent.0, accuracy: 1)
XCTAssertEqual(normal.1, agent.1, accuracy: 1)
capture("polish-agent-" + name)
}
private func metrics() throws -> (CGFloat, CGFloat) {
let user = app.staticTexts["User first\nUser second"]
let peer = app.staticTexts["Peer first\nPeer second"]
XCTAssertTrue(user.waitForExistence(timeout: 8)); XCTAssertTrue(peer.waitForExistence(timeout: 8))
let userParagraph = app.staticTexts["User paragraph"], peerParagraph = app.staticTexts["Peer paragraph"]
XCTAssertTrue(userParagraph.exists); XCTAssertTrue(peerParagraph.exists)
XCTAssertEqual(user.frame.height, peer.frame.height, accuracy: 1)
let gap = userParagraph.frame.minY - user.frame.maxY
XCTAssertEqual(gap, peerParagraph.frame.minY - peer.frame.maxY, accuracy: 1)
XCTAssertGreaterThan(gap, 0)
return (user.frame.height, gap)
}
func testLiteralUserAndSemanticAssistantMarkdown() async throws {
try await control(["action": "presentation-samples", "messages": [
["role": "user", "content": "**literal bold** `literal code`\n- literal item\n[not a link](https://example.invalid)"],
["role": "assistant", "content": "Soft break\ncontinues here.\n\n**Real bold** and [Local link](http://127.0.0.1:8799).\n\n- First item\n- Second item\n\n```swift\nlet value = 42\n```"]]])
app.launch(); openChat()
assertMarkdownSemantics(); capture("polish-normal-literal-and-markdown")
openAgent()
assertMarkdownSemantics(); capture("polish-agent-literal-and-markdown")
try await control(["action": "long-tool"])
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
}
private func assertMarkdownSemantics() {
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "**literal bold**")).firstMatch.waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "[not a link](https://example.invalid)")).firstMatch.exists)
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "First item")).firstMatch.exists)
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "let value = 42")).firstMatch.exists)
XCTAssertTrue(app.links["Local link"].exists)
}
}