Files
clawdbot/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift
2025-12-16 20:35:03 +01:00

231 lines
7.7 KiB
Swift

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 animate = false
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.reduceMotion ? 0.85 : (self.animate ? 1.05 : 0.70))
.opacity(self.reduceMotion ? 0.55 : (self.animate ? 0.95 : 0.30))
.animation(
self.reduceMotion ? nil : .easeInOut(duration: 0.55)
.repeatForever(autoreverses: true)
.delay(Double(idx) * 0.16),
value: self.animate)
}
}
.onAppear {
guard !self.reduceMotion else { return }
self.animate = true
}
}
}
@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))
}
}