Add a floating button with down chevron that appears when scrolled up from the bottom of the conversation. Tapping it smoothly scrolls to the latest messages. Uses ultraThinMaterial background with subtle shadow. Closes #2466 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
510 lines
18 KiB
Swift
510 lines
18 KiB
Swift
import SwiftUI
|
|
|
|
@MainActor
|
|
public struct ClawdbotChatView: View {
|
|
public enum Style {
|
|
case standard
|
|
case onboarding
|
|
}
|
|
|
|
@State private var viewModel: ClawdbotChatViewModel
|
|
@State private var scrollerBottomID = UUID()
|
|
@State private var scrollPosition: UUID?
|
|
@State private var showSessions = false
|
|
@State private var hasPerformedInitialScroll = false
|
|
@State private var isPinnedToBottom = true
|
|
private let showsSessionSwitcher: Bool
|
|
private let style: Style
|
|
private let markdownVariant: ChatMarkdownVariant
|
|
private let userAccent: Color?
|
|
|
|
private enum Layout {
|
|
#if os(macOS)
|
|
static let outerPaddingHorizontal: CGFloat = 6
|
|
static let outerPaddingVertical: CGFloat = 0
|
|
static let composerPaddingHorizontal: CGFloat = 0
|
|
static let stackSpacing: CGFloat = 0
|
|
static let messageSpacing: CGFloat = 6
|
|
static let messageListPaddingTop: CGFloat = 12
|
|
static let messageListPaddingBottom: CGFloat = 16
|
|
static let messageListPaddingHorizontal: CGFloat = 6
|
|
#else
|
|
static let outerPaddingHorizontal: CGFloat = 6
|
|
static let outerPaddingVertical: CGFloat = 6
|
|
static let composerPaddingHorizontal: CGFloat = 6
|
|
static let stackSpacing: CGFloat = 6
|
|
static let messageSpacing: CGFloat = 12
|
|
static let messageListPaddingTop: CGFloat = 10
|
|
static let messageListPaddingBottom: CGFloat = 6
|
|
static let messageListPaddingHorizontal: CGFloat = 8
|
|
#endif
|
|
}
|
|
|
|
public init(
|
|
viewModel: ClawdbotChatViewModel,
|
|
showsSessionSwitcher: Bool = false,
|
|
style: Style = .standard,
|
|
markdownVariant: ChatMarkdownVariant = .standard,
|
|
userAccent: Color? = nil)
|
|
{
|
|
self._viewModel = State(initialValue: viewModel)
|
|
self.showsSessionSwitcher = showsSessionSwitcher
|
|
self.style = style
|
|
self.markdownVariant = markdownVariant
|
|
self.userAccent = userAccent
|
|
}
|
|
|
|
public var body: some View {
|
|
ZStack {
|
|
if self.style == .standard {
|
|
ClawdbotChatTheme.background
|
|
.ignoresSafeArea()
|
|
}
|
|
|
|
VStack(spacing: Layout.stackSpacing) {
|
|
self.messageList
|
|
.padding(.horizontal, Layout.outerPaddingHorizontal)
|
|
ClawdbotChatComposer(
|
|
viewModel: self.viewModel,
|
|
style: self.style,
|
|
showsSessionSwitcher: self.showsSessionSwitcher)
|
|
.padding(.horizontal, Layout.composerPaddingHorizontal)
|
|
}
|
|
.padding(.vertical, Layout.outerPaddingVertical)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
.onAppear { self.viewModel.load() }
|
|
.sheet(isPresented: self.$showSessions) {
|
|
if self.showsSessionSwitcher {
|
|
ChatSessionsSheet(viewModel: self.viewModel)
|
|
} else {
|
|
EmptyView()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var messageList: some View {
|
|
ZStack {
|
|
ScrollView {
|
|
LazyVStack(spacing: Layout.messageSpacing) {
|
|
self.messageListRows
|
|
|
|
Color.clear
|
|
#if os(macOS)
|
|
.frame(height: Layout.messageListPaddingBottom)
|
|
#else
|
|
.frame(height: Layout.messageListPaddingBottom + 1)
|
|
#endif
|
|
.id(self.scrollerBottomID)
|
|
}
|
|
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
|
|
.scrollTargetLayout()
|
|
.padding(.top, Layout.messageListPaddingTop)
|
|
.padding(.horizontal, Layout.messageListPaddingHorizontal)
|
|
}
|
|
// Keep the scroll pinned to the bottom for new messages.
|
|
.scrollPosition(id: self.$scrollPosition, anchor: .bottom)
|
|
.onChange(of: self.scrollPosition) { _, position in
|
|
guard let position else { return }
|
|
self.isPinnedToBottom = position == self.scrollerBottomID
|
|
}
|
|
|
|
if self.viewModel.isLoading {
|
|
ProgressView()
|
|
.controlSize(.large)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
self.messageListOverlay
|
|
|
|
if !self.isPinnedToBottom, self.hasPerformedInitialScroll {
|
|
VStack {
|
|
Spacer()
|
|
Button {
|
|
withAnimation(.snappy(duration: 0.22)) {
|
|
self.scrollPosition = self.scrollerBottomID
|
|
}
|
|
} label: {
|
|
Image(systemName: "chevron.down")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
.frame(width: 32, height: 32)
|
|
.background(.ultraThinMaterial, in: Circle())
|
|
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(.bottom, 8)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.transition(.opacity.combined(with: .scale(scale: 0.8)))
|
|
.animation(.easeOut(duration: 0.15), value: self.isPinnedToBottom)
|
|
}
|
|
}
|
|
// Ensure the message list claims vertical space on the first layout pass.
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
.layoutPriority(1)
|
|
.onChange(of: self.viewModel.isLoading) { _, isLoading in
|
|
guard !isLoading, !self.hasPerformedInitialScroll else { return }
|
|
self.scrollPosition = self.scrollerBottomID
|
|
self.hasPerformedInitialScroll = true
|
|
self.isPinnedToBottom = true
|
|
}
|
|
.onChange(of: self.viewModel.sessionKey) { _, _ in
|
|
self.hasPerformedInitialScroll = false
|
|
self.isPinnedToBottom = true
|
|
}
|
|
.onChange(of: self.viewModel.messages.count) { _, _ in
|
|
guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
|
|
withAnimation(.snappy(duration: 0.22)) {
|
|
self.scrollPosition = self.scrollerBottomID
|
|
}
|
|
}
|
|
.onChange(of: self.viewModel.pendingRunCount) { _, _ in
|
|
guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
|
|
withAnimation(.snappy(duration: 0.22)) {
|
|
self.scrollPosition = self.scrollerBottomID
|
|
}
|
|
}
|
|
.onChange(of: self.viewModel.streamingAssistantText) { _, _ in
|
|
guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
|
|
withAnimation(.snappy(duration: 0.22)) {
|
|
self.scrollPosition = self.scrollerBottomID
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var messageListRows: some View {
|
|
ForEach(self.visibleMessages) { msg in
|
|
ChatMessageBubble(
|
|
message: msg,
|
|
style: self.style,
|
|
markdownVariant: self.markdownVariant,
|
|
userAccent: self.userAccent)
|
|
.frame(
|
|
maxWidth: .infinity,
|
|
alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
|
|
}
|
|
|
|
if self.viewModel.pendingRunCount > 0 {
|
|
HStack {
|
|
ChatTypingIndicatorBubble(style: self.style)
|
|
.equatable()
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
|
|
if !self.viewModel.pendingToolCalls.isEmpty {
|
|
ChatPendingToolsBubble(toolCalls: self.viewModel.pendingToolCalls)
|
|
.equatable()
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
if let text = self.viewModel.streamingAssistantText, AssistantTextParser.hasVisibleContent(in: text) {
|
|
ChatStreamingAssistantBubble(text: text, markdownVariant: self.markdownVariant)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
private var visibleMessages: [ClawdbotChatMessage] {
|
|
let base: [ClawdbotChatMessage]
|
|
if self.style == .onboarding {
|
|
guard let first = self.viewModel.messages.first else { return [] }
|
|
base = first.role.lowercased() == "user" ? Array(self.viewModel.messages.dropFirst()) : self.viewModel
|
|
.messages
|
|
} else {
|
|
base = self.viewModel.messages
|
|
}
|
|
return self.mergeToolResults(in: base)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var messageListOverlay: some View {
|
|
if self.viewModel.isLoading {
|
|
EmptyView()
|
|
} else if let error = self.activeErrorText {
|
|
let presentation = self.errorPresentation(for: error)
|
|
if self.hasVisibleMessageListContent {
|
|
VStack(spacing: 0) {
|
|
ChatNoticeBanner(
|
|
systemImage: presentation.systemImage,
|
|
title: presentation.title,
|
|
message: error,
|
|
tint: presentation.tint,
|
|
dismiss: { self.viewModel.errorText = nil },
|
|
refresh: { self.viewModel.refresh() })
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.top, 8)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
} else {
|
|
ChatNoticeCard(
|
|
systemImage: presentation.systemImage,
|
|
title: presentation.title,
|
|
message: error,
|
|
tint: presentation.tint,
|
|
actionTitle: "Refresh",
|
|
action: { self.viewModel.refresh() })
|
|
.padding(.horizontal, 24)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
} else if self.showsEmptyState {
|
|
ChatNoticeCard(
|
|
systemImage: "bubble.left.and.bubble.right.fill",
|
|
title: self.emptyStateTitle,
|
|
message: self.emptyStateMessage,
|
|
tint: .accentColor,
|
|
actionTitle: nil,
|
|
action: nil)
|
|
.padding(.horizontal, 24)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
|
|
private var activeErrorText: String? {
|
|
guard let text = self.viewModel.errorText?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!text.isEmpty
|
|
else {
|
|
return nil
|
|
}
|
|
return text
|
|
}
|
|
|
|
private var hasVisibleMessageListContent: Bool {
|
|
if !self.visibleMessages.isEmpty {
|
|
return true
|
|
}
|
|
if let text = self.viewModel.streamingAssistantText,
|
|
AssistantTextParser.hasVisibleContent(in: text)
|
|
{
|
|
return true
|
|
}
|
|
if self.viewModel.pendingRunCount > 0 {
|
|
return true
|
|
}
|
|
if !self.viewModel.pendingToolCalls.isEmpty {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
private var showsEmptyState: Bool {
|
|
self.viewModel.messages.isEmpty &&
|
|
!(self.viewModel.streamingAssistantText.map { AssistantTextParser.hasVisibleContent(in: $0) } ?? false) &&
|
|
self.viewModel.pendingRunCount == 0 &&
|
|
self.viewModel.pendingToolCalls.isEmpty
|
|
}
|
|
|
|
private var emptyStateTitle: String {
|
|
#if os(macOS)
|
|
"Web Chat"
|
|
#else
|
|
"Chat"
|
|
#endif
|
|
}
|
|
|
|
private var emptyStateMessage: String {
|
|
#if os(macOS)
|
|
"Type a message below to start.\nReturn sends • Shift-Return adds a line break."
|
|
#else
|
|
"Type a message below to start."
|
|
#endif
|
|
}
|
|
|
|
private func errorPresentation(for error: String) -> (title: String, systemImage: String, tint: Color) {
|
|
let lower = error.lowercased()
|
|
if lower.contains("not connected") || lower.contains("socket") {
|
|
return ("Disconnected", "wifi.slash", .orange)
|
|
}
|
|
if lower.contains("timed out") {
|
|
return ("Timed out", "clock.badge.exclamationmark", .orange)
|
|
}
|
|
return ("Error", "exclamationmark.triangle.fill", .orange)
|
|
}
|
|
|
|
private func mergeToolResults(in messages: [ClawdbotChatMessage]) -> [ClawdbotChatMessage] {
|
|
var result: [ClawdbotChatMessage] = []
|
|
result.reserveCapacity(messages.count)
|
|
|
|
for message in messages {
|
|
guard self.isToolResultMessage(message) else {
|
|
result.append(message)
|
|
continue
|
|
}
|
|
|
|
guard let toolCallId = message.toolCallId,
|
|
let last = result.last,
|
|
self.toolCallIds(in: last).contains(toolCallId)
|
|
else {
|
|
result.append(message)
|
|
continue
|
|
}
|
|
|
|
let toolText = self.toolResultText(from: message)
|
|
if toolText.isEmpty {
|
|
continue
|
|
}
|
|
|
|
var content = last.content
|
|
content.append(
|
|
ClawdbotChatMessageContent(
|
|
type: "tool_result",
|
|
text: toolText,
|
|
thinking: nil,
|
|
thinkingSignature: nil,
|
|
mimeType: nil,
|
|
fileName: nil,
|
|
content: nil,
|
|
id: toolCallId,
|
|
name: message.toolName,
|
|
arguments: nil))
|
|
|
|
let merged = ClawdbotChatMessage(
|
|
id: last.id,
|
|
role: last.role,
|
|
content: content,
|
|
timestamp: last.timestamp,
|
|
toolCallId: last.toolCallId,
|
|
toolName: last.toolName,
|
|
usage: last.usage,
|
|
stopReason: last.stopReason)
|
|
result[result.count - 1] = merged
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
private func isToolResultMessage(_ message: ClawdbotChatMessage) -> Bool {
|
|
let role = message.role.lowercased()
|
|
return role == "toolresult" || role == "tool_result"
|
|
}
|
|
|
|
private func toolCallIds(in message: ClawdbotChatMessage) -> Set<String> {
|
|
var ids = Set<String>()
|
|
for content in message.content {
|
|
let kind = (content.type ?? "").lowercased()
|
|
let isTool =
|
|
["toolcall", "tool_call", "tooluse", "tool_use"].contains(kind) ||
|
|
(content.name != nil && content.arguments != nil)
|
|
if isTool, let id = content.id {
|
|
ids.insert(id)
|
|
}
|
|
}
|
|
if let toolCallId = message.toolCallId {
|
|
ids.insert(toolCallId)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
private func toolResultText(from message: ClawdbotChatMessage) -> String {
|
|
let parts = message.content.compactMap { content -> String? in
|
|
let kind = (content.type ?? "text").lowercased()
|
|
guard kind == "text" || kind.isEmpty else { return nil }
|
|
return content.text
|
|
}
|
|
return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
}
|
|
|
|
private struct ChatNoticeCard: View {
|
|
let systemImage: String
|
|
let title: String
|
|
let message: String
|
|
let tint: Color
|
|
let actionTitle: String?
|
|
let action: (() -> Void)?
|
|
|
|
var body: some View {
|
|
VStack(spacing: 12) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(self.tint.opacity(0.16))
|
|
Image(systemName: self.systemImage)
|
|
.font(.system(size: 24, weight: .semibold))
|
|
.foregroundStyle(self.tint)
|
|
}
|
|
.frame(width: 52, height: 52)
|
|
|
|
Text(self.title)
|
|
.font(.headline)
|
|
|
|
Text(self.message)
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.lineLimit(4)
|
|
.frame(maxWidth: 360)
|
|
|
|
if let actionTitle, let action {
|
|
Button(actionTitle, action: action)
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
.padding(18)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
.fill(ClawdbotChatTheme.subtleCard)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)))
|
|
.shadow(color: .black.opacity(0.14), radius: 18, y: 8)
|
|
}
|
|
}
|
|
|
|
private struct ChatNoticeBanner: View {
|
|
let systemImage: String
|
|
let title: String
|
|
let message: String
|
|
let tint: Color
|
|
let dismiss: () -> Void
|
|
let refresh: () -> Void
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 10) {
|
|
Image(systemName: self.systemImage)
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundStyle(self.tint)
|
|
.padding(.top, 1)
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(self.title)
|
|
.font(.caption.weight(.semibold))
|
|
|
|
Text(self.message)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
Button(action: self.refresh) {
|
|
Image(systemName: "arrow.clockwise")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
.help("Refresh")
|
|
|
|
Button(action: self.dismiss) {
|
|
Image(systemName: "xmark")
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(.secondary)
|
|
.help("Dismiss")
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.fill(ClawdbotChatTheme.subtleCard)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)))
|
|
}
|
|
}
|