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(