ios: unify attachment controls and message typography
This commit is contained in:
@@ -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("<script>"))
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user