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
@@ -32,8 +32,9 @@ struct AgentPickedPhoto: Transferable, Sendable {
}
}
struct AgentAttachmentComposer: View {
struct AgentAttachmentComposer<Prompt: View>: View {
@Bindable var room: SybilAgentRoom
@ViewBuilder var prompt: () -> Prompt
@State private var modalID = UUID()
@State private var pickerSession: UUID?
@State private var showChoices = false
@@ -78,10 +79,17 @@ struct AgentAttachmentComposer: View {
if !room.attachmentDraft.supportsAttachments && !room.attachmentDraft.items.isEmpty {
Text(AgentAttachmentIssue.unavailable.localizedDescription).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger)
}
HStack(alignment: .bottom, spacing: 10) {
if room.attachmentDraft.supportsAttachments {
Button { pickerSession = room.attachmentSessionID; showChoices = true } label: { Label("Attach", systemImage: "paperclip") }
.font(.sybil(.caption, weight: .semibold)).accessibilityIdentifier("agent-attach")
.disabled(room.attachmentDraft.items.count >= (room.attachmentDraft.capability?.countLimit ?? 0))
SybilPromptAttachmentButton(
isEnabled: room.attachmentDraft.items.count < (room.attachmentDraft.capability?.countLimit ?? 0)
) {
pickerSession = room.attachmentSessionID
showChoices = true
}
.accessibilityIdentifier("agent-attach")
}
prompt()
}
}
.confirmationDialog("Add attachment", isPresented: $showChoices) {
@@ -205,9 +205,7 @@ struct SybilAgentView: View {
}
private var composer: some View {
VStack(alignment: .leading, spacing: 8) {
AgentAttachmentComposer(room: room)
HStack(alignment: .bottom, spacing: 10) {
AgentAttachmentComposer(room: room) {
SybilPromptField(
text: $room.composer,
focused: $composerFocused,
@@ -218,7 +216,6 @@ struct SybilAgentView: View {
SybilPromptSendButton(canSend: canSendComposer, submit: submitComposer)
.accessibilityIdentifier("agent-send")
}
}
.padding(.horizontal, 14)
.padding(.bottom, 12)
.padding(.top, 6)
@@ -230,15 +230,7 @@ struct MessageBubble: View {
presentation: matchingStreamingAssistantPresentation
)
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if isUser {
Text(message.content)
.font(.sybil(.body))
} else {
Markdown(message.content)
.tint(SybilTheme.primary)
.foregroundStyle(SybilTheme.text.opacity(0.95))
.markdownTheme(.sybilReadable)
}
SybilMessageText(content: message.content, format: isUser ? .literal : .markdown)
}
}
.padding(.horizontal, isUser || agentStyle ? 14 : 2)
@@ -371,10 +363,7 @@ struct StreamingAssistantContentView: View {
}
if hasAnswer {
Markdown(content)
.tint(SybilTheme.primary)
.foregroundStyle(SybilTheme.text.opacity(0.95))
.markdownTheme(.sybilReadable)
SybilMessageText(content: content, format: .markdown)
.textSelection(.enabled)
.id("streaming-assistant-answer")
.transition(answerTransition)
@@ -0,0 +1,36 @@
import MarkdownUI
import SwiftUI
/// Both roles and both workspaces use normal chat's readable, Dynamic Type-aware theme.
/// Literal input is built from text nodes, never passed through the Markdown parser.
struct SybilMessageText: View {
enum Format { case literal, markdown }
var content: String
var format: Format
static func literalContent(_ text: String) -> MarkdownContent {
// Pasted CRLF/CR and blank lines containing spaces or tabs have the same
// paragraph boundaries as Markdown. Keep the newline count so deliberate
// extra blank lines survive; nonblank lines and Markdown syntax stay literal.
let normalized = text.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
.components(separatedBy: "\n")
.map { line in line.allSatisfy { $0 == " " || $0 == "\t" } ? "" : line }
.joined(separator: "\n")
return MarkdownContent {
for paragraph in normalized.components(separatedBy: "\n\n") {
Paragraph {
// Keep extra blank paragraphs visible, including leading/trailing ones.
paragraph.isEmpty ? "\u{00a0}" : paragraph
}
}
}
}
var body: some View {
Markdown(format == .literal ? Self.literalContent(content) : MarkdownContent(content))
.tint(SybilTheme.primary)
.foregroundStyle(format == .literal ? SybilTheme.text : SybilTheme.text.opacity(0.95))
.markdownTheme(.sybilReadable)
}
}
@@ -60,3 +60,26 @@ struct SybilPromptSendButton: View {
.accessibilityLabel("Send message")
}
}
/// The normal chat attachment control, shared with Agent's composer.
/// Picker ownership and availability stay with each composer.
struct SybilPromptAttachmentButton: View {
var isEnabled: Bool
var action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: "paperclip")
.font(.system(size: 17, weight: .semibold))
.frame(width: 40, height: 40)
.background(Circle().fill(SybilTheme.surface))
.overlay(Circle().stroke(SybilTheme.border.opacity(0.82), lineWidth: 1))
.foregroundStyle(isEnabled ? SybilTheme.text : SybilTheme.textMuted)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!isEnabled)
.accessibilityLabel("Attach file")
}
}
@@ -592,25 +592,10 @@ struct SybilWorkspaceView: View {
HStack(alignment: .bottom, spacing: 10) {
if !viewModel.isSearchMode {
Button {
SybilPromptAttachmentButton(isEnabled: !viewModel.isActiveSelectionSending) {
isShowingAttachmentOptions = true
} label: {
Image(systemName: "paperclip")
.font(.system(size: 17, weight: .semibold))
.frame(width: 40, height: 40)
.background(
Circle()
.fill(SybilTheme.surface)
)
.overlay(
Circle()
.stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
)
.foregroundStyle(viewModel.isActiveSelectionSending ? SybilTheme.textMuted : SybilTheme.text)
}
.buttonStyle(.plain)
.disabled(viewModel.isActiveSelectionSending)
.accessibilityLabel("Attach file")
.accessibilityIdentifier("chat-attach")
}
SybilPromptField(
@@ -0,0 +1,54 @@
import MarkdownUI
import SwiftUI
import Testing
import UIKit
@testable import Sybil
@Suite @MainActor
struct MessagePresentationTests {
@Test func userMarkupRemainsLiteral() {
let source = "**bold** [link](https://example.invalid) `code`\n- item\n<script>literal</script>"
let content = SybilMessageText.literalContent(source)
#expect(content.renderPlainText() == source)
#expect(!content.renderHTML().contains("<strong>"))
#expect(!content.renderHTML().contains("<a "))
#expect(!content.renderHTML().contains("<code>"))
#expect(!content.renderHTML().contains("<ul>"))
#expect(content.renderHTML().contains("&lt;script&gt;"))
}
@Test func literalLineBreaksAndParagraphsStayDistinct() {
let content = SybilMessageText.literalContent("First\nSecond\n\nThird")
#expect(content.renderPlainText() == "First\nSecond\n\nThird")
#expect(content.renderHTML() == "<p>First\nSecond</p>\n<p>Third</p>\n")
// Empty paragraphs retain layout space instead of disappearing.
#expect(SybilMessageText.literalContent("\n\nFirst\n\n\n\nLast\n\n").renderHTML().components(separatedBy: "<p>").count == 6)
}
@Test func pastedParagraphBoundariesMatchMarkdown() {
for source in ["First\r\n\r\nSecond", "First\n \nSecond", "First\n\t\nSecond"] {
#expect(SybilMessageText.literalContent(source).renderHTML() == "<p>First</p>\n<p>Second</p>\n")
}
#expect(SybilMessageText.literalContent("First\r\nSecond").renderPlainText() == "First\nSecond")
#expect(SybilMessageText.literalContent(" **literal** \n\nLast").renderPlainText() == " **literal** \n\nLast")
#expect(SybilMessageText.literalContent("First\r\n \r\n\t\r\n\r\nLast") == SybilMessageText.literalContent("First\n\n\n\nLast"))
}
@Test func bothFormatsShareLineParagraphMetricsAndDynamicType() {
func height(_ content: String, _ format: SybilMessageText.Format, _ size: DynamicTypeSize) -> CGFloat {
let host = UIHostingController(rootView: SybilMessageText(content: content, format: format)
.environment(\.dynamicTypeSize, size))
return host.sizeThatFits(in: CGSize(width: 320, height: 10_000)).height
}
let literal = "First line\nSecond line\n\nAnother paragraph"
let markdown = "First line \nSecond line\n\nAnother paragraph"
for size in [DynamicTypeSize.large, .accessibility3] {
#expect(abs(height(literal, .literal, size) - height(markdown, .markdown, size)) < 1)
}
for source in ["First\r\n\r\nSecond", "First\n \nSecond", "First\n\t\nSecond"] {
#expect(abs(height(source, .literal, .large) - height(source, .markdown, .large)) < 1)
}
#expect(height(literal, .literal, .accessibility3) > height(literal, .literal, .large) * 1.5)
#expect(height(markdown, .markdown, .accessibility3) > height(markdown, .markdown, .large) * 1.5)
}
}
+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)
}
}
+30
View File
@@ -11,6 +11,22 @@ include:
# Dedicated local-fixture UI suite; ordinary release archives only build SybilApp.
targets:
SybilPresentationTests:
type: bundle.unit-test
platform: iOS
deploymentTarget: "18.0"
sources:
- Tests/SybilPresentationTests
dependencies:
- target: SybilApp
- package: Sybil
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2.presentation-tests
GENERATE_INFOPLIST_FILE: YES
SWIFT_VERSION: 6.0
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Sybil.app/Sybil"
BUNDLE_LOADER: "$(TEST_HOST)"
SybilAgentUITests:
type: bundle.ui-testing
platform: iOS
@@ -27,6 +43,20 @@ targets:
TEST_TARGET_NAME: SybilApp
SWIFT_VERSION: 6.0
schemes:
SybilPresentation:
run:
commandLineArguments:
"-sybil.ios.apiBaseURL http://127.0.0.1:8799/api -sybil.ios.adminToken fixture-token": true
environmentVariables:
SYBIL_PUSH_FIXTURE: "1"
build:
targets:
SybilApp: all
SybilPresentationTests: [test]
test:
config: Debug
targets:
- SybilPresentationTests
SybilAgentUI:
build:
targets: