feat(chat): restyle onboarding chat UI

main
Peter Steinberger 2025-12-20 16:51:39 +00:00
parent 6b56f7d643
commit 5936ed7941
4 changed files with 142 additions and 89 deletions

View File

@ -10,6 +10,7 @@ import UniformTypeIdentifiers
@MainActor @MainActor
struct ClawdisChatComposer: View { struct ClawdisChatComposer: View {
@Bindable var viewModel: ClawdisChatViewModel @Bindable var viewModel: ClawdisChatViewModel
let style: ClawdisChatView.Style
#if !os(macOS) #if !os(macOS)
@State private var pickerItems: [PhotosPickerItem] = [] @State private var pickerItems: [PhotosPickerItem] = []
@ -18,14 +19,16 @@ struct ClawdisChatComposer: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) { if self.showsToolbar {
self.thinkingPicker HStack(spacing: 8) {
Spacer() self.thinkingPicker
self.refreshButton Spacer()
self.attachmentPicker self.refreshButton
self.attachmentPicker
}
} }
if !self.viewModel.attachments.isEmpty { if self.showsAttachments && !self.viewModel.attachments.isEmpty {
self.attachmentsStrip self.attachmentsStrip
} }
@ -40,9 +43,9 @@ struct ClawdisChatComposer: View {
} }
.padding(8) .padding(8)
.background( .background(
RoundedRectangle(cornerRadius: 16, style: .continuous) RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(ClawdisChatTheme.card) .fill(ClawdisChatTheme.composerBackground)
.shadow(color: .black.opacity(0.06), radius: 12, y: 6)) .shadow(color: .black.opacity(0.08), radius: 10, y: 4))
#if os(macOS) #if os(macOS)
.onDrop(of: [.fileURL], isTargeted: nil) { providers in .onDrop(of: [.fileURL], isTargeted: nil) { providers in
self.handleDrop(providers) self.handleDrop(providers)
@ -126,15 +129,17 @@ struct ClawdisChatComposer: View {
private var editor: some View { private var editor: some View {
RoundedRectangle(cornerRadius: 12, style: .continuous) RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(ClawdisChatTheme.divider) .strokeBorder(ClawdisChatTheme.composerBorder)
.background( .background(
RoundedRectangle(cornerRadius: 12, style: .continuous) RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(ClawdisChatTheme.card)) .fill(ClawdisChatTheme.composerField))
.overlay { .overlay {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
self.editorOverlay self.editorOverlay
HStack(alignment: .bottom, spacing: 8) { HStack(alignment: .bottom, spacing: 8) {
self.connectionPill if self.showsConnectionPill {
self.connectionPill
}
Spacer(minLength: 0) Spacer(minLength: 0)
self.sendButton self.sendButton
} }
@ -195,29 +200,32 @@ struct ClawdisChatComposer: View {
self.viewModel.abort() self.viewModel.abort()
} label: { } label: {
if self.viewModel.isAborting { if self.viewModel.isAborting {
ProgressView().controlSize(.small) ProgressView().controlSize(.mini)
} else { } else {
Image(systemName: "stop.fill") Image(systemName: "stop.fill")
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
} }
} }
.buttonStyle(.bordered) .buttonStyle(.plain)
.tint(.red) .foregroundStyle(.white)
.controlSize(.small) .padding(8)
.background(Circle().fill(Color.red))
.disabled(self.viewModel.isAborting) .disabled(self.viewModel.isAborting)
} else { } else {
Button { Button {
self.viewModel.send() self.viewModel.send()
} label: { } label: {
if self.viewModel.isSending { if self.viewModel.isSending {
ProgressView().controlSize(.small) ProgressView().controlSize(.mini)
} else { } else {
Image(systemName: "arrow.up") Image(systemName: "arrow.up")
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
} }
} }
.buttonStyle(.borderedProminent) .buttonStyle(.plain)
.controlSize(.small) .foregroundStyle(.white)
.padding(8)
.background(Circle().fill(Color.accentColor))
.disabled(!self.viewModel.canSend) .disabled(!self.viewModel.canSend)
} }
} }
@ -234,6 +242,18 @@ struct ClawdisChatComposer: View {
.help("Refresh") .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) #if os(macOS)
private func pickFilesMac() { private func pickFilesMac() {
let panel = NSOpenPanel() let panel = NSOpenPanel()

View File

@ -2,8 +2,8 @@ import Foundation
import SwiftUI import SwiftUI
private enum ChatUIConstants { private enum ChatUIConstants {
static let bubbleMaxWidth: CGFloat = 760 static let bubbleMaxWidth: CGFloat = 560
static let bubbleCorner: CGFloat = 16 static let bubbleCorner: CGFloat = 18
} }
@MainActor @MainActor
@ -11,27 +11,10 @@ struct ChatMessageBubble: View {
let message: ClawdisChatMessage let message: ClawdisChatMessage
var body: some View { var body: some View {
VStack(alignment: self.isUser ? .trailing : .leading, spacing: 8) { ChatMessageBody(message: self.message, isUser: self.isUser)
HStack(spacing: 8) { .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
if !self.isUser { .frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading)
Label("Assistant", systemImage: "sparkles") .padding(.horizontal, 2)
.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" } private var isUser: Bool { self.message.role.lowercased() == "user" }
@ -45,14 +28,15 @@ private struct ChatMessageBody: View {
var body: some View { var body: some View {
let text = self.primaryText let text = self.primaryText
let split = ChatMarkdownSplitter.split(markdown: text) let split = ChatMarkdownSplitter.split(markdown: text)
let textColor = self.isUser ? ClawdisChatTheme.userText : ClawdisChatTheme.assistantText
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
ForEach(split.blocks) { block in ForEach(split.blocks) { block in
switch block.kind { switch block.kind {
case .text: case .text:
MarkdownTextView(text: block.text) MarkdownTextView(text: block.text, textColor: textColor)
case let .code(language): 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 { if !self.inlineAttachments.isEmpty {
ForEach(self.inlineAttachments.indices, id: \.self) { idx in ForEach(self.inlineAttachments.indices, id: \.self) { idx in
AttachmentRow(att: self.inlineAttachments[idx]) AttachmentRow(att: self.inlineAttachments[idx], isUser: self.isUser)
} }
} }
} }
.textSelection(.enabled) .textSelection(.enabled)
.padding(12) .padding(.vertical, 10)
.padding(.horizontal, 12)
.foregroundStyle(textColor)
.background(self.bubbleBackground) .background(self.bubbleBackground)
.overlay(self.bubbleBorder) .overlay(self.bubbleBorder)
.clipShape(RoundedRectangle(cornerRadius: ChatUIConstants.bubbleCorner, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: ChatUIConstants.bubbleCorner, style: .continuous))
@ -101,27 +87,21 @@ private struct ChatMessageBody: View {
} }
private var bubbleBackground: AnyShapeStyle { private var bubbleBackground: AnyShapeStyle {
if self.isUser { let fill = self.isUser ? ClawdisChatTheme.userBubble : ClawdisChatTheme.assistantBubble
return AnyShapeStyle( return AnyShapeStyle(fill)
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 { private var bubbleBorder: some View {
RoundedRectangle(cornerRadius: ChatUIConstants.bubbleCorner, style: .continuous) 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 { private struct AttachmentRow: View {
let att: ClawdisChatMessageContent let att: ClawdisChatMessageContent
let isUser: Bool
var body: some View { var body: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
@ -129,10 +109,11 @@ private struct AttachmentRow: View {
Text(self.att.fileName ?? "Attachment") Text(self.att.fileName ?? "Attachment")
.font(.footnote) .font(.footnote)
.lineLimit(1) .lineLimit(1)
.foregroundStyle(self.isUser ? ClawdisChatTheme.userText : ClawdisChatTheme.assistantText)
Spacer() Spacer()
} }
.padding(10) .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)) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
} }
} }
@ -150,10 +131,10 @@ struct ChatTypingIndicatorBubble: View {
.padding(12) .padding(12)
.background( .background(
RoundedRectangle(cornerRadius: 16, style: .continuous) RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(ClawdisChatTheme.subtleCard)) .fill(ClawdisChatTheme.assistantBubble))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous) 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) .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
} }
} }
@ -164,19 +145,15 @@ struct ChatStreamingAssistantBubble: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Label("Assistant (streaming)", systemImage: "sparkles") ChatMarkdownBody(text: self.text, textColor: ClawdisChatTheme.assistantText)
.font(.caption)
.foregroundStyle(.secondary)
ChatMarkdownBody(text: self.text)
} }
.padding(12) .padding(12)
.background( .background(
RoundedRectangle(cornerRadius: 16, style: .continuous) RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(ClawdisChatTheme.subtleCard)) .fill(ClawdisChatTheme.assistantBubble))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous) 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) .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
} }
} }
@ -207,10 +184,10 @@ struct ChatPendingToolsBubble: View {
.padding(12) .padding(12)
.background( .background(
RoundedRectangle(cornerRadius: 16, style: .continuous) RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(ClawdisChatTheme.subtleCard)) .fill(ClawdisChatTheme.assistantBubble))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous) 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) .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
} }
} }
@ -245,16 +222,17 @@ private struct TypingDots: View {
@MainActor @MainActor
private struct MarkdownTextView: View { private struct MarkdownTextView: View {
let text: String let text: String
let textColor: Color
var body: some View { var body: some View {
if let attributed = try? AttributedString(markdown: self.text) { if let attributed = try? AttributedString(markdown: self.text) {
Text(attributed) Text(attributed)
.font(.system(size: 14)) .font(.system(size: 14))
.foregroundStyle(.primary) .foregroundStyle(self.textColor)
} else { } else {
Text(self.text) Text(self.text)
.font(.system(size: 14)) .font(.system(size: 14))
.foregroundStyle(.primary) .foregroundStyle(self.textColor)
} }
} }
} }
@ -262,6 +240,7 @@ private struct MarkdownTextView: View {
@MainActor @MainActor
private struct ChatMarkdownBody: View { private struct ChatMarkdownBody: View {
let text: String let text: String
let textColor: Color
var body: some View { var body: some View {
let split = ChatMarkdownSplitter.split(markdown: self.text) let split = ChatMarkdownSplitter.split(markdown: self.text)
@ -269,9 +248,9 @@ private struct ChatMarkdownBody: View {
ForEach(split.blocks) { block in ForEach(split.blocks) { block in
switch block.kind { switch block.kind {
case .text: case .text:
MarkdownTextView(text: block.text) MarkdownTextView(text: block.text, textColor: self.textColor)
case let .code(language): 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 { private struct CodeBlockView: View {
let code: String let code: String
let language: String? let language: String?
let isUser: Bool
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
@ -315,15 +295,15 @@ private struct CodeBlockView: View {
} }
Text(self.code) Text(self.code)
.font(.system(size: 13, weight: .regular, design: .monospaced)) .font(.system(size: 13, weight: .regular, design: .monospaced))
.foregroundStyle(.primary) .foregroundStyle(self.isUser ? .white : .primary)
.textSelection(.enabled) .textSelection(.enabled)
} }
.padding(12) .padding(12)
.frame(maxWidth: .infinity, alignment: .leading) .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( .overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous) 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)) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
} }
} }

View File

@ -31,6 +31,52 @@ enum ClawdisChatTheme {
#endif #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 { static var divider: Color {
Color.secondary.opacity(0.2) Color.secondary.opacity(0.2)
} }

View File

@ -2,19 +2,25 @@ import SwiftUI
@MainActor @MainActor
public struct ClawdisChatView: View { public struct ClawdisChatView: View {
public enum Style {
case standard
case onboarding
}
@State private var viewModel: ClawdisChatViewModel @State private var viewModel: ClawdisChatViewModel
@State private var scrollerBottomID = UUID() @State private var scrollerBottomID = UUID()
@State private var showSessions = false @State private var showSessions = false
private let showsSessionSwitcher: Bool private let showsSessionSwitcher: Bool
private let style: Style
private enum Layout { private enum Layout {
#if os(macOS) #if os(macOS)
static let outerPadding: CGFloat = 2 static let outerPadding: CGFloat = 6
static let stackSpacing: CGFloat = 3 static let stackSpacing: CGFloat = 6
static let messageSpacing: CGFloat = 8 static let messageSpacing: CGFloat = 6
static let messageListPaddingTop: CGFloat = 0 static let messageListPaddingTop: CGFloat = 2
static let messageListPaddingBottom: CGFloat = 2 static let messageListPaddingBottom: CGFloat = 4
static let messageListPaddingHorizontal: CGFloat = 4 static let messageListPaddingHorizontal: CGFloat = 6
#else #else
static let outerPadding: CGFloat = 6 static let outerPadding: CGFloat = 6
static let stackSpacing: CGFloat = 6 static let stackSpacing: CGFloat = 6
@ -25,9 +31,14 @@ public struct ClawdisChatView: View {
#endif #endif
} }
public init(viewModel: ClawdisChatViewModel, showsSessionSwitcher: Bool = false) { public init(
viewModel: ClawdisChatViewModel,
showsSessionSwitcher: Bool = false,
style: Style = .standard)
{
self._viewModel = State(initialValue: viewModel) self._viewModel = State(initialValue: viewModel)
self.showsSessionSwitcher = showsSessionSwitcher self.showsSessionSwitcher = showsSessionSwitcher
self.style = style
} }
public var body: some View { public var body: some View {
@ -37,7 +48,7 @@ public struct ClawdisChatView: View {
VStack(spacing: Layout.stackSpacing) { VStack(spacing: Layout.stackSpacing) {
self.messageList self.messageList
ClawdisChatComposer(viewModel: self.viewModel) ClawdisChatComposer(viewModel: self.viewModel, style: self.style)
} }
.padding(.horizontal, Layout.outerPadding) .padding(.horizontal, Layout.outerPadding)
.padding(.vertical, Layout.outerPadding) .padding(.vertical, Layout.outerPadding)
@ -88,10 +99,6 @@ public struct ClawdisChatView: View {
.padding(.bottom, Layout.messageListPaddingBottom) .padding(.bottom, Layout.messageListPaddingBottom)
.padding(.horizontal, Layout.messageListPaddingHorizontal) .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 .onChange(of: self.viewModel.messages.count) { _, _ in
withAnimation(.snappy(duration: 0.22)) { withAnimation(.snappy(duration: 0.22)) {
proxy.scrollTo(self.scrollerBottomID, anchor: .bottom) proxy.scrollTo(self.scrollerBottomID, anchor: .bottom)