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 { @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)) } } .onAppear { withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { self.phase = 1 } } } private func dotScale(_ idx: Int) -> CGFloat { let base = 0.85 + (self.phase * 0.35) let offset = Double(idx) * 0.15 return CGFloat(base - offset) } } @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)) } }