import Foundation import SwiftUI private enum ChatUIConstants { static let bubbleMaxWidth: CGFloat = 760 static let bubbleCorner: CGFloat = 16 } @MainActor struct ChatMessageBubble: View { let message: ClawdisChatMessage var body: some View { VStack(alignment: self.isUser ? .trailing : .leading, spacing: 8) { HStack(spacing: 8) { if !self.isUser { Label("Assistant", systemImage: "sparkles") .labelStyle(.titleAndIcon) .font(.caption) .foregroundStyle(.secondary) } Spacer(minLength: 0) if self.isUser { Label("You", systemImage: "person.fill") .labelStyle(.titleAndIcon) .font(.caption) .foregroundStyle(.secondary) } } ChatMessageBody(message: self.message, isUser: self.isUser) .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading) } .padding(.horizontal, 2) } private var isUser: Bool { self.message.role.lowercased() == "user" } } @MainActor private struct ChatMessageBody: View { let message: ClawdisChatMessage let isUser: Bool var body: some View { let text = self.primaryText let split = ChatMarkdownSplitter.split(markdown: text) VStack(alignment: .leading, spacing: 10) { ForEach(split.blocks) { block in switch block.kind { case .text: MarkdownTextView(text: block.text) case let .code(language): CodeBlockView(code: block.text, language: language) } } if !split.images.isEmpty { ForEach( split.images, id: \ChatMarkdownSplitter.InlineImage.id) { (item: ChatMarkdownSplitter.InlineImage) in if let img = item.image { ClawdisPlatformImageFactory.image(img) .resizable() .scaledToFit() .frame(maxHeight: 260) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder(Color.white.opacity(0.12), lineWidth: 1)) } else { Text(item.label.isEmpty ? "Image" : item.label) .font(.footnote) .foregroundStyle(.secondary) } } } if !self.inlineAttachments.isEmpty { ForEach(self.inlineAttachments.indices, id: \.self) { idx in AttachmentRow(att: self.inlineAttachments[idx]) } } } .textSelection(.enabled) .padding(12) .background(self.bubbleBackground) .overlay(self.bubbleBorder) .clipShape(RoundedRectangle(cornerRadius: ChatUIConstants.bubbleCorner, style: .continuous)) } private var primaryText: String { let parts = self.message.content.compactMap(\.text) return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) } private var inlineAttachments: [ClawdisChatMessageContent] { self.message.content.filter { ($0.type ?? "text") != "text" } } private var bubbleBackground: AnyShapeStyle { if self.isUser { return AnyShapeStyle( LinearGradient( colors: [ Color.orange.opacity(0.22), Color.accentColor.opacity(0.18), ], startPoint: .topLeading, endPoint: .bottomTrailing)) } return AnyShapeStyle(ClawdisChatTheme.subtleCard) } private var bubbleBorder: some View { RoundedRectangle(cornerRadius: ChatUIConstants.bubbleCorner, style: .continuous) .strokeBorder(self.isUser ? Color.orange.opacity(0.35) : Color.white.opacity(0.10), lineWidth: 1) } } private struct AttachmentRow: View { let att: ClawdisChatMessageContent var body: some View { HStack(spacing: 8) { Image(systemName: "paperclip") Text(self.att.fileName ?? "Attachment") .font(.footnote) .lineLimit(1) Spacer() } .padding(10) .background(Color.white.opacity(0.06)) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } } @MainActor struct ChatTypingIndicatorBubble: View { var body: some View { HStack(spacing: 10) { TypingDots() Text("Clawd is thinking…") .font(.subheadline) .foregroundStyle(.secondary) Spacer() } .padding(12) .background( RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(ClawdisChatTheme.subtleCard)) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .strokeBorder(Color.white.opacity(0.10), lineWidth: 1)) .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading) } } @MainActor private struct TypingDots: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var phase: Double = 0 var body: some View { HStack(spacing: 5) { ForEach(0..<3, id: \.self) { idx in Circle() .fill(Color.secondary.opacity(0.55)) .frame(width: 7, height: 7) .scaleEffect(self.dotScale(idx)) .opacity(self.dotOpacity(idx)) } } .onAppear { guard !self.reduceMotion else { return } phase = 0 withAnimation(.linear(duration: 1.05).repeatForever(autoreverses: false)) { self.phase = .pi * 2 } } } private func dotScale(_ idx: Int) -> CGFloat { if self.reduceMotion { return 0.85 } let wave = self.dotWave(idx) return CGFloat(0.72 + (wave * 0.52)) } private func dotOpacity(_ idx: Int) -> Double { if self.reduceMotion { return 0.55 } let wave = self.dotWave(idx) return 0.35 + (wave * 0.65) } private func dotWave(_ idx: Int) -> Double { let offset = (Double(idx) * (2 * Double.pi / 3)) return (sin(self.phase + offset) + 1) / 2 } } @MainActor private struct MarkdownTextView: View { let text: String var body: some View { if let attributed = try? AttributedString(markdown: self.text) { Text(attributed) .font(.system(size: 14)) .foregroundStyle(.primary) } else { Text(self.text) .font(.system(size: 14)) .foregroundStyle(.primary) } } } @MainActor private struct CodeBlockView: View { let code: String let language: String? var body: some View { VStack(alignment: .leading, spacing: 8) { if let language, !language.isEmpty { Text(language) .font(.caption2.monospaced()) .foregroundStyle(.secondary) } Text(self.code) .font(.system(size: 13, weight: .regular, design: .monospaced)) .foregroundStyle(.primary) .textSelection(.enabled) } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.black.opacity(0.06)) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder(Color.white.opacity(0.10), lineWidth: 1)) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } }