openclaw/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatView.swift
Kenny Lee 96dbca6170 feat(macos): add scroll-to-bottom button in webchat
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>
2026-01-26 16:30:20 -08:00

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)))
}
}