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