diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift index b1a820137..dcf81e1f5 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift @@ -10,6 +10,7 @@ import UniformTypeIdentifiers @MainActor struct ClawdisChatComposer: View { @Bindable var viewModel: ClawdisChatViewModel + let style: ClawdisChatView.Style #if !os(macOS) @State private var pickerItems: [PhotosPickerItem] = [] @@ -18,14 +19,16 @@ struct ClawdisChatComposer: View { var body: some View { VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - self.thinkingPicker - Spacer() - self.refreshButton - self.attachmentPicker + if self.showsToolbar { + HStack(spacing: 8) { + self.thinkingPicker + Spacer() + self.refreshButton + self.attachmentPicker + } } - if !self.viewModel.attachments.isEmpty { + if self.showsAttachments && !self.viewModel.attachments.isEmpty { self.attachmentsStrip } @@ -40,9 +43,9 @@ struct ClawdisChatComposer: View { } .padding(8) .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(ClawdisChatTheme.card) - .shadow(color: .black.opacity(0.06), radius: 12, y: 6)) + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(ClawdisChatTheme.composerBackground) + .shadow(color: .black.opacity(0.08), radius: 10, y: 4)) #if os(macOS) .onDrop(of: [.fileURL], isTargeted: nil) { providers in self.handleDrop(providers) @@ -126,15 +129,17 @@ struct ClawdisChatComposer: View { private var editor: some View { RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(ClawdisChatTheme.divider) + .strokeBorder(ClawdisChatTheme.composerBorder) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(ClawdisChatTheme.card)) + .fill(ClawdisChatTheme.composerField)) .overlay { VStack(alignment: .leading, spacing: 6) { self.editorOverlay HStack(alignment: .bottom, spacing: 8) { - self.connectionPill + if self.showsConnectionPill { + self.connectionPill + } Spacer(minLength: 0) self.sendButton } @@ -195,29 +200,32 @@ struct ClawdisChatComposer: View { self.viewModel.abort() } label: { if self.viewModel.isAborting { - ProgressView().controlSize(.small) + ProgressView().controlSize(.mini) } else { Image(systemName: "stop.fill") .font(.system(size: 13, weight: .semibold)) } } - .buttonStyle(.bordered) - .tint(.red) - .controlSize(.small) + .buttonStyle(.plain) + .foregroundStyle(.white) + .padding(8) + .background(Circle().fill(Color.red)) .disabled(self.viewModel.isAborting) } else { Button { self.viewModel.send() } label: { if self.viewModel.isSending { - ProgressView().controlSize(.small) + ProgressView().controlSize(.mini) } else { Image(systemName: "arrow.up") .font(.system(size: 13, weight: .semibold)) } } - .buttonStyle(.borderedProminent) - .controlSize(.small) + .buttonStyle(.plain) + .foregroundStyle(.white) + .padding(8) + .background(Circle().fill(Color.accentColor)) .disabled(!self.viewModel.canSend) } } @@ -234,6 +242,18 @@ struct ClawdisChatComposer: View { .help("Refresh") } + private var showsToolbar: Bool { + self.style == .standard + } + + private var showsAttachments: Bool { + self.style == .standard + } + + private var showsConnectionPill: Bool { + self.style == .standard + } + #if os(macOS) private func pickFilesMac() { let panel = NSOpenPanel() diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift index 818c02996..bba3ef2d7 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift @@ -2,8 +2,8 @@ import Foundation import SwiftUI private enum ChatUIConstants { - static let bubbleMaxWidth: CGFloat = 760 - static let bubbleCorner: CGFloat = 16 + static let bubbleMaxWidth: CGFloat = 560 + static let bubbleCorner: CGFloat = 18 } @MainActor @@ -11,27 +11,10 @@ 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) + ChatMessageBody(message: self.message, isUser: self.isUser) + .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading) + .frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading) + .padding(.horizontal, 2) } private var isUser: Bool { self.message.role.lowercased() == "user" } @@ -45,14 +28,15 @@ private struct ChatMessageBody: View { var body: some View { let text = self.primaryText let split = ChatMarkdownSplitter.split(markdown: text) + let textColor = self.isUser ? ClawdisChatTheme.userText : ClawdisChatTheme.assistantText VStack(alignment: .leading, spacing: 10) { ForEach(split.blocks) { block in switch block.kind { case .text: - MarkdownTextView(text: block.text) + MarkdownTextView(text: block.text, textColor: textColor) case let .code(language): - CodeBlockView(code: block.text, language: language) + CodeBlockView(code: block.text, language: language, isUser: self.isUser) } } @@ -80,12 +64,14 @@ private struct ChatMessageBody: View { if !self.inlineAttachments.isEmpty { ForEach(self.inlineAttachments.indices, id: \.self) { idx in - AttachmentRow(att: self.inlineAttachments[idx]) + AttachmentRow(att: self.inlineAttachments[idx], isUser: self.isUser) } } } .textSelection(.enabled) - .padding(12) + .padding(.vertical, 10) + .padding(.horizontal, 12) + .foregroundStyle(textColor) .background(self.bubbleBackground) .overlay(self.bubbleBorder) .clipShape(RoundedRectangle(cornerRadius: ChatUIConstants.bubbleCorner, style: .continuous)) @@ -101,27 +87,21 @@ private struct ChatMessageBody: View { } 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) + let fill = self.isUser ? ClawdisChatTheme.userBubble : ClawdisChatTheme.assistantBubble + return AnyShapeStyle(fill) } 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) + .strokeBorder( + self.isUser ? Color.white.opacity(0.12) : Color.black.opacity(0.08), + lineWidth: self.isUser ? 0.5 : 1) } } private struct AttachmentRow: View { let att: ClawdisChatMessageContent + let isUser: Bool var body: some View { HStack(spacing: 8) { @@ -129,10 +109,11 @@ private struct AttachmentRow: View { Text(self.att.fileName ?? "Attachment") .font(.footnote) .lineLimit(1) + .foregroundStyle(self.isUser ? ClawdisChatTheme.userText : ClawdisChatTheme.assistantText) Spacer() } .padding(10) - .background(Color.white.opacity(0.06)) + .background(self.isUser ? Color.white.opacity(0.2) : Color.black.opacity(0.04)) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } } @@ -150,10 +131,10 @@ struct ChatTypingIndicatorBubble: View { .padding(12) .background( RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(ClawdisChatTheme.subtleCard)) + .fill(ClawdisChatTheme.assistantBubble)) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(Color.white.opacity(0.10), lineWidth: 1)) + .strokeBorder(Color.black.opacity(0.08), lineWidth: 1)) .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading) } } @@ -164,19 +145,15 @@ struct ChatStreamingAssistantBubble: View { var body: some View { VStack(alignment: .leading, spacing: 10) { - Label("Assistant (streaming)", systemImage: "sparkles") - .font(.caption) - .foregroundStyle(.secondary) - - ChatMarkdownBody(text: self.text) + ChatMarkdownBody(text: self.text, textColor: ClawdisChatTheme.assistantText) } .padding(12) .background( RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(ClawdisChatTheme.subtleCard)) + .fill(ClawdisChatTheme.assistantBubble)) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(Color.white.opacity(0.10), lineWidth: 1)) + .strokeBorder(Color.black.opacity(0.08), lineWidth: 1)) .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading) } } @@ -207,10 +184,10 @@ struct ChatPendingToolsBubble: View { .padding(12) .background( RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(ClawdisChatTheme.subtleCard)) + .fill(ClawdisChatTheme.assistantBubble)) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(Color.white.opacity(0.10), lineWidth: 1)) + .strokeBorder(Color.black.opacity(0.08), lineWidth: 1)) .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading) } } @@ -245,16 +222,17 @@ private struct TypingDots: View { @MainActor private struct MarkdownTextView: View { let text: String + let textColor: Color var body: some View { if let attributed = try? AttributedString(markdown: self.text) { Text(attributed) .font(.system(size: 14)) - .foregroundStyle(.primary) + .foregroundStyle(self.textColor) } else { Text(self.text) .font(.system(size: 14)) - .foregroundStyle(.primary) + .foregroundStyle(self.textColor) } } } @@ -262,6 +240,7 @@ private struct MarkdownTextView: View { @MainActor private struct ChatMarkdownBody: View { let text: String + let textColor: Color var body: some View { let split = ChatMarkdownSplitter.split(markdown: self.text) @@ -269,9 +248,9 @@ private struct ChatMarkdownBody: View { ForEach(split.blocks) { block in switch block.kind { case .text: - MarkdownTextView(text: block.text) + MarkdownTextView(text: block.text, textColor: self.textColor) case let .code(language): - CodeBlockView(code: block.text, language: language) + CodeBlockView(code: block.text, language: language, isUser: false) } } @@ -305,6 +284,7 @@ private struct ChatMarkdownBody: View { private struct CodeBlockView: View { let code: String let language: String? + let isUser: Bool var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -315,15 +295,15 @@ private struct CodeBlockView: View { } Text(self.code) .font(.system(size: 13, weight: .regular, design: .monospaced)) - .foregroundStyle(.primary) + .foregroundStyle(self.isUser ? .white : .primary) .textSelection(.enabled) } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.black.opacity(0.06)) + .background(self.isUser ? Color.white.opacity(0.16) : Color.black.opacity(0.06)) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(Color.white.opacity(0.10), lineWidth: 1)) + .strokeBorder(Color.black.opacity(0.08), lineWidth: 1)) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } } diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatTheme.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatTheme.swift index 5f2dafae1..242c3bf82 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatTheme.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatTheme.swift @@ -31,6 +31,52 @@ enum ClawdisChatTheme { #endif } + static var userBubble: Color { + #if os(macOS) + Color(nsColor: .systemBlue) + #else + Color(uiColor: .systemBlue) + #endif + } + + static var assistantBubble: Color { + #if os(macOS) + Color(nsColor: .controlBackgroundColor) + #else + Color(uiColor: .secondarySystemBackground) + #endif + } + + static var userText: Color { .white } + + static var assistantText: Color { + #if os(macOS) + Color(nsColor: .labelColor) + #else + Color(uiColor: .label) + #endif + } + + static var composerBackground: Color { + #if os(macOS) + Color(nsColor: .windowBackgroundColor) + #else + Color(uiColor: .systemBackground) + #endif + } + + static var composerField: Color { + #if os(macOS) + Color(nsColor: .textBackgroundColor) + #else + Color(uiColor: .secondarySystemBackground) + #endif + } + + static var composerBorder: Color { + Color.secondary.opacity(0.2) + } + static var divider: Color { Color.secondary.opacity(0.2) } diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift index e19022294..227b030e7 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift @@ -2,19 +2,25 @@ import SwiftUI @MainActor public struct ClawdisChatView: View { + public enum Style { + case standard + case onboarding + } + @State private var viewModel: ClawdisChatViewModel @State private var scrollerBottomID = UUID() @State private var showSessions = false private let showsSessionSwitcher: Bool + private let style: Style private enum Layout { #if os(macOS) - static let outerPadding: CGFloat = 2 - static let stackSpacing: CGFloat = 3 - static let messageSpacing: CGFloat = 8 - static let messageListPaddingTop: CGFloat = 0 - static let messageListPaddingBottom: CGFloat = 2 - static let messageListPaddingHorizontal: CGFloat = 4 + static let outerPadding: CGFloat = 6 + static let stackSpacing: CGFloat = 6 + static let messageSpacing: CGFloat = 6 + static let messageListPaddingTop: CGFloat = 2 + static let messageListPaddingBottom: CGFloat = 4 + static let messageListPaddingHorizontal: CGFloat = 6 #else static let outerPadding: CGFloat = 6 static let stackSpacing: CGFloat = 6 @@ -25,9 +31,14 @@ public struct ClawdisChatView: View { #endif } - public init(viewModel: ClawdisChatViewModel, showsSessionSwitcher: Bool = false) { + public init( + viewModel: ClawdisChatViewModel, + showsSessionSwitcher: Bool = false, + style: Style = .standard) + { self._viewModel = State(initialValue: viewModel) self.showsSessionSwitcher = showsSessionSwitcher + self.style = style } public var body: some View { @@ -37,7 +48,7 @@ public struct ClawdisChatView: View { VStack(spacing: Layout.stackSpacing) { self.messageList - ClawdisChatComposer(viewModel: self.viewModel) + ClawdisChatComposer(viewModel: self.viewModel, style: self.style) } .padding(.horizontal, Layout.outerPadding) .padding(.vertical, Layout.outerPadding) @@ -88,10 +99,6 @@ public struct ClawdisChatView: View { .padding(.bottom, Layout.messageListPaddingBottom) .padding(.horizontal, Layout.messageListPaddingHorizontal) } - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(ClawdisChatTheme.card) - .shadow(color: .black.opacity(0.05), radius: 12, y: 6)) .onChange(of: self.viewModel.messages.count) { _, _ in withAnimation(.snappy(duration: 0.22)) { proxy.scrollTo(self.scrollerBottomID, anchor: .bottom)