From 60a35509d4e80b99c6c3bc812706dcdb7de3d4bc Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 14 Oct 2026 22:03:21 -0700 Subject: [PATCH] ios: unify attachment controls and message typography --- .../Sybil/SybilAgentAttachmentViews.swift | 18 ++- .../Sybil/Sources/Sybil/SybilAgentView.swift | 21 ++-- .../Sybil/SybilChatTranscriptView.swift | 15 +-- .../Sources/Sybil/SybilMessageText.swift | 36 ++++++ .../Sources/Sybil/SybilPromptField.swift | 23 ++++ .../Sources/Sybil/SybilWorkspaceView.swift | 19 +-- .../SybilTests/MessagePresentationTests.swift | 54 ++++++++ ios/Tests/Fixtures/agent_server.py | 6 + .../AgentComposerPresentationTests.swift | 110 ++++++++++++++++ .../SybilUITests/AgentPolishUITests.swift | 118 ++++++++++++++++++ ios/project.yml | 30 +++++ 11 files changed, 403 insertions(+), 47 deletions(-) create mode 100644 ios/Packages/Sybil/Sources/Sybil/SybilMessageText.swift create mode 100644 ios/Packages/Sybil/Tests/SybilTests/MessagePresentationTests.swift create mode 100644 ios/Tests/SybilPresentationTests/AgentComposerPresentationTests.swift create mode 100644 ios/Tests/SybilUITests/AgentPolishUITests.swift diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAgentAttachmentViews.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAgentAttachmentViews.swift index ebdc39b..fdbee38 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAgentAttachmentViews.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAgentAttachmentViews.swift @@ -32,8 +32,9 @@ struct AgentPickedPhoto: Transferable, Sendable { } } -struct AgentAttachmentComposer: View { +struct AgentAttachmentComposer: 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) } - 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)) + HStack(alignment: .bottom, spacing: 10) { + if room.attachmentDraft.supportsAttachments { + 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) { diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift index 096a50a..8f249c0 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift @@ -205,19 +205,16 @@ struct SybilAgentView: View { } private var composer: some View { - VStack(alignment: .leading, spacing: 8) { - AgentAttachmentComposer(room: room) - HStack(alignment: .bottom, spacing: 10) { - SybilPromptField( - text: $room.composer, - focused: $composerFocused, - submit: submitComposer - ) - .accessibilityIdentifier("agent-composer") + AgentAttachmentComposer(room: room) { + SybilPromptField( + text: $room.composer, + focused: $composerFocused, + submit: submitComposer + ) + .accessibilityIdentifier("agent-composer") - SybilPromptSendButton(canSend: canSendComposer, submit: submitComposer) - .accessibilityIdentifier("agent-send") - } + SybilPromptSendButton(canSend: canSendComposer, submit: submitComposer) + .accessibilityIdentifier("agent-send") } .padding(.horizontal, 14) .padding(.bottom, 12) diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift index 4c6df7f..2101217 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift @@ -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) diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilMessageText.swift b/ios/Packages/Sybil/Sources/Sybil/SybilMessageText.swift new file mode 100644 index 0000000..a1e771f --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilMessageText.swift @@ -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) + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilPromptField.swift b/ios/Packages/Sybil/Sources/Sybil/SybilPromptField.swift index 7c0b94a..c0ed974 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilPromptField.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilPromptField.swift @@ -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") + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift index 909d1b7..55e59ec 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift @@ -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( diff --git a/ios/Packages/Sybil/Tests/SybilTests/MessagePresentationTests.swift b/ios/Packages/Sybil/Tests/SybilTests/MessagePresentationTests.swift new file mode 100644 index 0000000..4d8cd4f --- /dev/null +++ b/ios/Packages/Sybil/Tests/SybilTests/MessagePresentationTests.swift @@ -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" + let content = SybilMessageText.literalContent(source) + #expect(content.renderPlainText() == source) + #expect(!content.renderHTML().contains("")) + #expect(!content.renderHTML().contains("")) + #expect(!content.renderHTML().contains("
    ")) + #expect(content.renderHTML().contains("<script>")) + } + + @Test func literalLineBreaksAndParagraphsStayDistinct() { + let content = SybilMessageText.literalContent("First\nSecond\n\nThird") + #expect(content.renderPlainText() == "First\nSecond\n\nThird") + #expect(content.renderHTML() == "

    First\nSecond

    \n

    Third

    \n") + // Empty paragraphs retain layout space instead of disappearing. + #expect(SybilMessageText.literalContent("\n\nFirst\n\n\n\nLast\n\n").renderHTML().components(separatedBy: "

    ").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() == "

    First

    \n

    Second

    \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) + } +} diff --git a/ios/Tests/Fixtures/agent_server.py b/ios/Tests/Fixtures/agent_server.py index a725c04..179052c 100644 --- a/ios/Tests/Fixtures/agent_server.py +++ b/ios/Tests/Fixtures/agent_server.py @@ -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': diff --git a/ios/Tests/SybilPresentationTests/AgentComposerPresentationTests.swift b/ios/Tests/SybilPresentationTests/AgentComposerPresentationTests.swift new file mode 100644 index 0000000..55bcec2 --- /dev/null +++ b/ios/Tests/SybilPresentationTests/AgentComposerPresentationTests.swift @@ -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: {}) + } + } +} diff --git a/ios/Tests/SybilUITests/AgentPolishUITests.swift b/ios/Tests/SybilUITests/AgentPolishUITests.swift new file mode 100644 index 0000000..5cb6c7d --- /dev/null +++ b/ios/Tests/SybilUITests/AgentPolishUITests.swift @@ -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) + } +} diff --git a/ios/project.yml b/ios/project.yml index 4f3b31a..b861a9e 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -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: