Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
972
apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift
Normal file
972
apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift
Normal file
@@ -0,0 +1,972 @@
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
enum ChatReaderUserTransition: Equatable {
|
||||
case unchanged
|
||||
case added(UUID)
|
||||
case removed(latestRemainingID: UUID?)
|
||||
}
|
||||
|
||||
func chatReaderUserTransition(
|
||||
previousID: UUID?,
|
||||
visibleIDs: [UUID]) -> ChatReaderUserTransition
|
||||
{
|
||||
let latestID = visibleIDs.last
|
||||
if let previousID, !visibleIDs.contains(previousID) {
|
||||
return .removed(latestRemainingID: latestID)
|
||||
}
|
||||
if let latestID, latestID != previousID {
|
||||
return .added(latestID)
|
||||
}
|
||||
return .unchanged
|
||||
}
|
||||
|
||||
func chatReaderHasNewerContent(
|
||||
after messageID: UUID,
|
||||
visibleIDs: [UUID],
|
||||
hasTransientContent: Bool) -> Bool
|
||||
{
|
||||
guard let messageIndex = visibleIDs.firstIndex(of: messageID) else { return false }
|
||||
return messageIndex < visibleIDs.index(before: visibleIDs.endIndex) || hasTransientContent
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public struct OpenClawChatView: View {
|
||||
public enum Style {
|
||||
case standard
|
||||
case onboarding
|
||||
}
|
||||
|
||||
public enum ComposerChrome {
|
||||
case full
|
||||
case clean
|
||||
}
|
||||
|
||||
public struct StarterPrompt: Hashable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
public let prompt: String
|
||||
|
||||
public init(id: String, title: String, prompt: String) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.prompt = prompt
|
||||
}
|
||||
}
|
||||
|
||||
@State private var viewModel: OpenClawChatViewModel
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var scrollerBottomID = UUID()
|
||||
@State private var scrollPosition: UUID?
|
||||
@State private var showSessions = false
|
||||
@State private var hasPerformedInitialScroll = false
|
||||
@State private var lastUserMessageID: UUID?
|
||||
@State private var hasNewerContentBelow = false
|
||||
@State private var followTarget: ScrollFollowTarget? = .latest
|
||||
@State private var isAtLiveEdge = true
|
||||
@State private var isUserScrolling = false
|
||||
private let showsSessionSwitcher: Bool
|
||||
private let drawsBackground: Bool
|
||||
private let style: Style
|
||||
private let markdownVariant: ChatMarkdownVariant
|
||||
private let userAccent: Color?
|
||||
private let showsAssistantTrace: Bool
|
||||
private let assistantName: String?
|
||||
private let assistantAvatarText: String?
|
||||
private let assistantAvatarTint: Color?
|
||||
private let showsAssistantAvatars: Bool
|
||||
private let composerChrome: ComposerChrome
|
||||
private let isComposerEnabled: Bool
|
||||
private let messagePlaceholder: String?
|
||||
private let emptyAssistantIntro: String?
|
||||
private let emptyAssistantPrompts: [StarterPrompt]
|
||||
private let talkControl: OpenClawChatTalkControl?
|
||||
|
||||
private enum ScrollFollowTarget: Equatable {
|
||||
case latest
|
||||
case user(UUID)
|
||||
}
|
||||
|
||||
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
|
||||
static let newTurnAnchor = UnitPoint(x: 0.5, y: 0.18)
|
||||
static let liveEdgeThreshold: CGFloat = 48
|
||||
#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
|
||||
static let newTurnAnchor = UnitPoint(x: 0.5, y: 0.18)
|
||||
static let liveEdgeThreshold: CGFloat = 48
|
||||
#endif
|
||||
}
|
||||
|
||||
public init(
|
||||
viewModel: OpenClawChatViewModel,
|
||||
drawsBackground: Bool = true,
|
||||
showsSessionSwitcher: Bool = false,
|
||||
style: Style = .standard,
|
||||
markdownVariant: ChatMarkdownVariant = .standard,
|
||||
userAccent: Color? = nil,
|
||||
showsAssistantTrace: Bool = false,
|
||||
assistantName: String? = nil,
|
||||
assistantAvatarText: String? = nil,
|
||||
assistantAvatarTint: Color? = nil,
|
||||
showsAssistantAvatars: Bool = true,
|
||||
composerChrome: ComposerChrome = .full,
|
||||
isComposerEnabled: Bool = true,
|
||||
messagePlaceholder: String? = nil,
|
||||
emptyAssistantIntro: String? = nil,
|
||||
emptyAssistantPrompts: [StarterPrompt] = [],
|
||||
talkControl: OpenClawChatTalkControl? = nil)
|
||||
{
|
||||
_viewModel = State(initialValue: viewModel)
|
||||
self.drawsBackground = drawsBackground
|
||||
self.showsSessionSwitcher = showsSessionSwitcher
|
||||
self.style = style
|
||||
self.markdownVariant = markdownVariant
|
||||
self.userAccent = userAccent
|
||||
self.showsAssistantTrace = showsAssistantTrace
|
||||
self.assistantName = assistantName
|
||||
self.assistantAvatarText = assistantAvatarText
|
||||
self.assistantAvatarTint = assistantAvatarTint
|
||||
self.showsAssistantAvatars = showsAssistantAvatars
|
||||
self.composerChrome = composerChrome
|
||||
self.isComposerEnabled = isComposerEnabled
|
||||
self.messagePlaceholder = messagePlaceholder
|
||||
self.emptyAssistantIntro = emptyAssistantIntro
|
||||
self.emptyAssistantPrompts = emptyAssistantPrompts
|
||||
self.talkControl = talkControl
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ZStack {
|
||||
if self.drawsBackground, self.style == .standard {
|
||||
OpenClawChatTheme.background
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
self.content
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.onAppear { self.viewModel.load() }
|
||||
.sheet(isPresented: self.$showSessions) {
|
||||
if self.showsSessionSwitcher {
|
||||
ChatSessionsSheet(viewModel: self.viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
#if os(macOS)
|
||||
VStack(spacing: Layout.stackSpacing) {
|
||||
self.messageList
|
||||
.padding(.horizontal, Layout.outerPaddingHorizontal)
|
||||
self.composer
|
||||
.padding(.horizontal, Layout.composerPaddingHorizontal)
|
||||
}
|
||||
.padding(.vertical, Layout.outerPaddingVertical)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
#else
|
||||
VStack(spacing: 0) {
|
||||
self.messageList
|
||||
.padding(.horizontal, Layout.outerPaddingHorizontal)
|
||||
self.composer
|
||||
.padding(.horizontal, Layout.composerPaddingHorizontal)
|
||||
.padding(.top, Layout.stackSpacing)
|
||||
.padding(.bottom, Layout.outerPaddingVertical)
|
||||
}
|
||||
.padding(.top, Layout.outerPaddingVertical)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var composer: some View {
|
||||
OpenClawChatComposer(
|
||||
viewModel: self.viewModel,
|
||||
style: self.style,
|
||||
showsSessionSwitcher: self.showsSessionSwitcher,
|
||||
userAccent: self.userAccent,
|
||||
assistantName: self.assistantName,
|
||||
assistantAvatarText: self.assistantAvatarText,
|
||||
assistantAvatarTint: self.assistantAvatarTint,
|
||||
composerChrome: self.composerChrome,
|
||||
isComposerEnabled: self.isComposerEnabled,
|
||||
messagePlaceholder: self.messagePlaceholder,
|
||||
talkControl: self.talkControl)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
#if !os(macOS)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
#endif
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
self.messageListNoticeBanner
|
||||
}
|
||||
.scrollPosition(id: self.$scrollPosition, anchor: .bottom)
|
||||
.onScrollGeometryChange(for: Bool.self) { geometry in
|
||||
let distanceFromBottom = geometry.contentSize.height - geometry.visibleRect.maxY
|
||||
return distanceFromBottom <= Layout.liveEdgeThreshold
|
||||
} action: { _, isAtLiveEdge in
|
||||
self.isAtLiveEdge = isAtLiveEdge
|
||||
guard self.hasPerformedInitialScroll else { return }
|
||||
if isAtLiveEdge, !self.isUserScrolling, !self.isFollowingUserTurn {
|
||||
self.followTarget = .latest
|
||||
self.hasNewerContentBelow = false
|
||||
}
|
||||
}
|
||||
.onScrollPhaseChange { _, phase in
|
||||
guard self.hasPerformedInitialScroll else { return }
|
||||
if phase == .interacting {
|
||||
self.isUserScrolling = true
|
||||
self.followTarget = nil
|
||||
} else if phase == .idle, self.isUserScrolling {
|
||||
self.isUserScrolling = false
|
||||
if self.isAtLiveEdge {
|
||||
self.followTarget = .latest
|
||||
self.hasNewerContentBelow = false
|
||||
} else {
|
||||
self.hasNewerContentBelow = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.viewModel.isLoading, self.composerChrome == .full {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
self.messageListOverlay
|
||||
|
||||
if self.showsJumpToLatest {
|
||||
self.jumpToLatestButton
|
||||
.padding(.bottom, 12)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
// Ensure the message list claims vertical space on the first layout pass.
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.layoutPriority(1)
|
||||
.simultaneousGesture(
|
||||
TapGesture().onEnded {
|
||||
self.dismissKeyboardIfNeeded()
|
||||
})
|
||||
.onChange(of: self.viewModel.isLoading) { _, isLoading in
|
||||
guard !isLoading, !self.hasPerformedInitialScroll else { return }
|
||||
self.restoreInitialScrollPosition()
|
||||
self.hasPerformedInitialScroll = true
|
||||
self.lastUserMessageID = self.latestVisibleUserMessageID
|
||||
}
|
||||
.onChange(of: self.viewModel.sessionKey) { _, _ in
|
||||
self.hasPerformedInitialScroll = false
|
||||
self.followTarget = .latest
|
||||
self.isAtLiveEdge = true
|
||||
self.isUserScrolling = false
|
||||
self.hasNewerContentBelow = false
|
||||
self.lastUserMessageID = nil
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
guard newValue == .active else { return }
|
||||
self.viewModel.resumeFromForeground()
|
||||
}
|
||||
.onChange(of: self.viewModel.timelineRevision) { _, _ in
|
||||
self.handleTimelineChange()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var messageListRows: some View {
|
||||
if let introText = visibleEmptyAssistantIntro {
|
||||
ChatAssistantIntroCard(
|
||||
text: introText,
|
||||
prompts: self.emptyAssistantPrompts,
|
||||
onPrompt: { prompt in
|
||||
self.viewModel.input = prompt.prompt
|
||||
self.viewModel.send()
|
||||
})
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
if self.showsCleanLoadingPlaceholder {
|
||||
ChatLoadingBubble()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
ForEach(self.visibleMessages) { msg in
|
||||
ChatMessageBubble(
|
||||
message: msg,
|
||||
style: self.style,
|
||||
markdownVariant: self.markdownVariant,
|
||||
userAccent: self.userAccent,
|
||||
showsAssistantTrace: self.showsAssistantTrace,
|
||||
assistantName: self.assistantName,
|
||||
assistantAvatarText: self.assistantAvatarText,
|
||||
assistantAvatarTint: self.assistantAvatarTint,
|
||||
showsAssistantAvatar: self.showsAssistantAvatars,
|
||||
isClean: self.composerChrome == .clean)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
|
||||
}
|
||||
|
||||
if self.viewModel.pendingRunCount > 0 {
|
||||
ChatTypingIndicatorBubble(
|
||||
style: self.style,
|
||||
assistantName: self.assistantName,
|
||||
assistantAvatarText: self.assistantAvatarText,
|
||||
assistantAvatarTint: self.assistantAvatarTint,
|
||||
showsAssistantAvatar: self.showsAssistantAvatars,
|
||||
isClean: self.composerChrome == .clean)
|
||||
.equatable()
|
||||
}
|
||||
|
||||
if !self.viewModel.pendingToolCalls.isEmpty {
|
||||
ChatPendingToolsBubble(
|
||||
toolCalls: self.viewModel.pendingToolCalls,
|
||||
isClean: self.composerChrome == .clean)
|
||||
.equatable()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
if let text = viewModel.streamingAssistantText,
|
||||
AssistantTextParser.hasVisibleContent(in: text, includeThinking: self.showsAssistantTrace)
|
||||
{
|
||||
ChatStreamingAssistantBubble(
|
||||
text: text,
|
||||
markdownVariant: self.markdownVariant,
|
||||
showsAssistantTrace: self.showsAssistantTrace,
|
||||
assistantName: self.assistantName,
|
||||
assistantAvatarText: self.assistantAvatarText,
|
||||
assistantAvatarTint: self.assistantAvatarTint,
|
||||
showsAssistantAvatar: self.showsAssistantAvatars,
|
||||
isClean: self.composerChrome == .clean)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private var visibleMessages: [OpenClawChatMessage] {
|
||||
let base: [OpenClawChatMessage]
|
||||
if self.style == .onboarding {
|
||||
guard let first = 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).filter(self.shouldDisplayMessage(_:))
|
||||
}
|
||||
|
||||
private var latestVisibleUserMessageID: UUID? {
|
||||
self.visibleUserMessageIDs.last
|
||||
}
|
||||
|
||||
private var visibleUserMessageIDs: [UUID] {
|
||||
self.visibleMessages.compactMap { message in
|
||||
message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user"
|
||||
? message.id
|
||||
: nil
|
||||
}
|
||||
}
|
||||
|
||||
private var isFollowingUserTurn: Bool {
|
||||
if case .user = self.followTarget {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private var showsJumpToLatest: Bool {
|
||||
self.hasNewerContentBelow && self.hasVisibleMessageListContent && !self.viewModel.isLoading
|
||||
}
|
||||
|
||||
private var jumpToLatestButton: some View {
|
||||
Button {
|
||||
self.followTarget = .latest
|
||||
self.hasNewerContentBelow = false
|
||||
self.moveScrollPosition(to: self.scrollerBottomID)
|
||||
} label: {
|
||||
Label("Jump to latest", systemImage: "arrow.down")
|
||||
.font(OpenClawChatTypography.body(size: 16, weight: .semibold, relativeTo: .callout))
|
||||
.padding(.horizontal, 13)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(OpenClawChatTheme.assistantText)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(OpenClawChatTheme.subtleCard)
|
||||
.shadow(color: .black.opacity(0.16), radius: 10, y: 4))
|
||||
.accessibilityLabel("Jump to latest reply")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var messageListOverlay: some View {
|
||||
if self.viewModel.isLoading {
|
||||
EmptyView()
|
||||
} else if self.composerChrome == .clean, self.visibleEmptyAssistantIntro != nil {
|
||||
EmptyView()
|
||||
} else if self.showsCleanLoadingPlaceholder {
|
||||
EmptyView()
|
||||
} else if let error = activeErrorText {
|
||||
if self.hasVisibleMessageListContent {
|
||||
EmptyView()
|
||||
} else {
|
||||
let presentation = self.errorPresentation(for: error)
|
||||
ChatNoticeCard(
|
||||
systemImage: presentation.systemImage,
|
||||
title: presentation.title,
|
||||
message: presentation.message,
|
||||
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,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
.padding(.horizontal, 24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private var activeErrorText: String? {
|
||||
guard let text = viewModel.errorText?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private var hasVisibleMessageListContent: Bool {
|
||||
if !self.visibleMessages.isEmpty {
|
||||
return true
|
||||
}
|
||||
return self.hasVisibleTransientContent
|
||||
}
|
||||
|
||||
private var hasVisibleTransientContent: Bool {
|
||||
self.viewModel.pendingRunCount > 0 ||
|
||||
!self.viewModel.pendingToolCalls.isEmpty ||
|
||||
(self.viewModel.streamingAssistantText.map {
|
||||
AssistantTextParser.hasVisibleContent(in: $0, includeThinking: self.showsAssistantTrace)
|
||||
} ?? false)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var messageListNoticeBanner: some View {
|
||||
if let error = activeErrorText,
|
||||
hasVisibleMessageListContent,
|
||||
!self.viewModel.isLoading,
|
||||
visibleEmptyAssistantIntro == nil,
|
||||
!self.showsCleanLoadingPlaceholder
|
||||
{
|
||||
let presentation = self.errorPresentation(for: error)
|
||||
ChatNoticeBanner(
|
||||
systemImage: presentation.systemImage,
|
||||
title: presentation.title,
|
||||
message: error,
|
||||
tint: presentation.tint,
|
||||
dismiss: { self.viewModel.errorText = nil },
|
||||
refresh: { self.viewModel.refresh() })
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private var showsCleanLoadingPlaceholder: Bool {
|
||||
self.composerChrome == .clean &&
|
||||
self.viewModel.isLoading &&
|
||||
self.visibleEmptyAssistantIntro == nil &&
|
||||
self.activeErrorText == nil &&
|
||||
!self.hasVisibleMessageListContent
|
||||
}
|
||||
|
||||
private var visibleEmptyAssistantIntro: String? {
|
||||
guard self.composerChrome == .clean,
|
||||
self.showsEmptyState,
|
||||
!self.viewModel.isLoading,
|
||||
self.activeErrorText == nil,
|
||||
self.isComposerEnabled
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
guard let text = emptyAssistantIntro?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private var showsEmptyState: Bool {
|
||||
self.viewModel.messages.isEmpty &&
|
||||
!(self.viewModel.streamingAssistantText.map {
|
||||
AssistantTextParser.hasVisibleContent(in: $0, includeThinking: self.showsAssistantTrace)
|
||||
} ?? 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, message: String, systemImage: String, tint: Color)
|
||||
{
|
||||
let lower = error.lowercased()
|
||||
if lower.contains("not connected") || lower.contains("socket") {
|
||||
return ("Disconnected", "Reconnect to your gateway to continue.", "wifi.slash", .orange)
|
||||
}
|
||||
if lower.contains("timed out") {
|
||||
return ("Timed out", "The gateway took too long to respond.", "clock.badge.exclamationmark", .orange)
|
||||
}
|
||||
// Unknown errors: keep the raw text as the description so it stays actionable.
|
||||
return ("Something went wrong", error, "exclamationmark.triangle.fill", .orange)
|
||||
}
|
||||
|
||||
private func restoreInitialScrollPosition() {
|
||||
if let latestUserMessageID = latestVisibleUserMessageID {
|
||||
self.followTarget = nil
|
||||
self.hasNewerContentBelow = chatReaderHasNewerContent(
|
||||
after: latestUserMessageID,
|
||||
visibleIDs: self.visibleMessages.map(\.id),
|
||||
hasTransientContent: self.hasVisibleTransientContent)
|
||||
self.moveScrollPosition(to: latestUserMessageID, anchor: Layout.newTurnAnchor)
|
||||
} else {
|
||||
self.followTarget = .latest
|
||||
self.hasNewerContentBelow = false
|
||||
self.moveScrollPosition(to: self.scrollerBottomID)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleTimelineChange() {
|
||||
guard self.hasPerformedInitialScroll else { return }
|
||||
if self.viewModel.messages.isEmpty,
|
||||
self.viewModel.pendingRunCount == 0,
|
||||
self.viewModel.pendingToolCalls.isEmpty,
|
||||
self.viewModel.streamingAssistantText == nil
|
||||
{
|
||||
self.lastUserMessageID = nil
|
||||
self.followTarget = .latest
|
||||
self.hasNewerContentBelow = false
|
||||
self.moveScrollPosition(to: self.scrollerBottomID)
|
||||
return
|
||||
}
|
||||
let visibleMessages = self.visibleMessages
|
||||
let visibleUserMessageIDs = visibleMessages.compactMap { message in
|
||||
message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user"
|
||||
? message.id
|
||||
: nil
|
||||
}
|
||||
switch chatReaderUserTransition(
|
||||
previousID: self.lastUserMessageID,
|
||||
visibleIDs: visibleUserMessageIDs)
|
||||
{
|
||||
case let .removed(latestRemainingID):
|
||||
self.lastUserMessageID = latestRemainingID
|
||||
if case let .user(messageID) = followTarget,
|
||||
!visibleUserMessageIDs.contains(messageID)
|
||||
{
|
||||
self.followTarget = nil
|
||||
self.hasNewerContentBelow = false
|
||||
}
|
||||
return
|
||||
case let .added(latestUserMessageID):
|
||||
self.lastUserMessageID = latestUserMessageID
|
||||
self.followTarget = .user(latestUserMessageID)
|
||||
self.hasNewerContentBelow = false
|
||||
self.moveScrollPosition(to: latestUserMessageID, anchor: Layout.newTurnAnchor)
|
||||
return
|
||||
case .unchanged:
|
||||
break
|
||||
}
|
||||
|
||||
switch self.followTarget {
|
||||
case .latest:
|
||||
self.hasNewerContentBelow = false
|
||||
self.moveScrollPosition(to: self.scrollerBottomID)
|
||||
case let .user(messageID):
|
||||
// Reader policy stays on this turn after the one-shot scroll binding is released. Reissuing
|
||||
// that target for every streaming delta can loop SwiftUI layout and starve interaction.
|
||||
self.hasNewerContentBelow = chatReaderHasNewerContent(
|
||||
after: messageID,
|
||||
visibleIDs: visibleMessages.map(\.id),
|
||||
hasTransientContent: self.hasVisibleTransientContent)
|
||||
case nil:
|
||||
self.hasNewerContentBelow = true
|
||||
}
|
||||
}
|
||||
|
||||
private func moveScrollPosition(
|
||||
to id: UUID,
|
||||
anchor: UnitPoint = .bottom)
|
||||
{
|
||||
var transaction = Transaction(animation: nil)
|
||||
transaction.scrollTargetAnchor = anchor
|
||||
withTransaction(transaction) {
|
||||
self.scrollPosition = id
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
guard self.scrollPosition == id else { return }
|
||||
// Reader policy lives in followTarget. The binding is only a one-shot positioning request;
|
||||
// keeping an overflowing transcript bound to any row can loop SwiftUI scroll layout.
|
||||
self.scrollPosition = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func mergeToolResults(in messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
|
||||
var result: [OpenClawChatMessage] = []
|
||||
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,
|
||||
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(
|
||||
OpenClawChatMessageContent(
|
||||
type: "tool_result",
|
||||
text: toolText,
|
||||
thinking: nil,
|
||||
thinkingSignature: nil,
|
||||
mimeType: nil,
|
||||
fileName: nil,
|
||||
content: nil,
|
||||
id: toolCallId,
|
||||
name: message.toolName,
|
||||
arguments: nil))
|
||||
|
||||
let merged = OpenClawChatMessage(
|
||||
id: last.id,
|
||||
role: last.role,
|
||||
content: content,
|
||||
timestamp: last.timestamp,
|
||||
idempotencyKey: last.idempotencyKey,
|
||||
toolCallId: last.toolCallId,
|
||||
toolName: last.toolName,
|
||||
usage: last.usage,
|
||||
stopReason: last.stopReason,
|
||||
errorMessage: last.errorMessage)
|
||||
result[result.count - 1] = merged
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private func isToolResultMessage(_ message: OpenClawChatMessage) -> Bool {
|
||||
let role = message.role.lowercased()
|
||||
return role == "toolresult" || role == "tool_result"
|
||||
}
|
||||
|
||||
private func shouldDisplayMessage(_ message: OpenClawChatMessage) -> Bool {
|
||||
if self.hasInlineAttachments(in: message) {
|
||||
return true
|
||||
}
|
||||
|
||||
let primaryText = self.primaryText(in: message)
|
||||
if !primaryText.isEmpty {
|
||||
if message.role.lowercased() == "user" {
|
||||
return true
|
||||
}
|
||||
if AssistantTextParser.hasVisibleContent(in: primaryText, includeThinking: self.showsAssistantTrace) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
guard self.showsAssistantTrace else {
|
||||
return false
|
||||
}
|
||||
|
||||
if self.isToolResultMessage(message) {
|
||||
return !primaryText.isEmpty
|
||||
}
|
||||
|
||||
return !self.toolCalls(in: message).isEmpty || !self.inlineToolResults(in: message).isEmpty
|
||||
}
|
||||
|
||||
private func primaryText(in message: OpenClawChatMessage) -> 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 OpenClawChatMessage.displayText(
|
||||
contentText: parts.joined(separator: "\n"),
|
||||
role: message.role,
|
||||
stopReason: message.stopReason,
|
||||
errorMessage: message.errorMessage)
|
||||
}
|
||||
|
||||
private func hasInlineAttachments(in message: OpenClawChatMessage) -> Bool {
|
||||
message.content.contains { content in
|
||||
switch content.type ?? "text" {
|
||||
case "file", "attachment":
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func toolCalls(in message: OpenClawChatMessage) -> [OpenClawChatMessageContent] {
|
||||
message.content.filter { content in
|
||||
let kind = (content.type ?? "").lowercased()
|
||||
if ["toolcall", "tool_call", "tooluse", "tool_use"].contains(kind) {
|
||||
return true
|
||||
}
|
||||
return content.name != nil && content.arguments != nil
|
||||
}
|
||||
}
|
||||
|
||||
private func inlineToolResults(in message: OpenClawChatMessage) -> [OpenClawChatMessageContent] {
|
||||
message.content.filter { content in
|
||||
let kind = (content.type ?? "").lowercased()
|
||||
return kind == "toolresult" || kind == "tool_result"
|
||||
}
|
||||
}
|
||||
|
||||
private func toolCallIds(in message: OpenClawChatMessage) -> Set<String> {
|
||||
var ids = Set<String>()
|
||||
for content in self.toolCalls(in: message) {
|
||||
if let id = content.id {
|
||||
ids.insert(id)
|
||||
}
|
||||
}
|
||||
if let toolCallId = message.toolCallId {
|
||||
ids.insert(toolCallId)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
private func toolResultText(from message: OpenClawChatMessage) -> String {
|
||||
self.primaryText(in: message)
|
||||
}
|
||||
|
||||
private func dismissKeyboardIfNeeded() {
|
||||
#if canImport(UIKit)
|
||||
UIApplication.shared.sendAction(
|
||||
#selector(UIResponder.resignFirstResponder),
|
||||
to: nil,
|
||||
from: nil,
|
||||
for: nil)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatAssistantIntroCard: View {
|
||||
let text: String
|
||||
let prompts: [OpenClawChatView.StarterPrompt]
|
||||
let onPrompt: (OpenClawChatView.StarterPrompt) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
// Rendered as a grey assistant bubble so the greeting reads like the
|
||||
// agent's first message, matching the in-conversation bubble style.
|
||||
Text(self.text)
|
||||
.font(OpenClawChatTypography.body)
|
||||
.foregroundStyle(OpenClawChatTheme.assistantText)
|
||||
.multilineTextAlignment(.leading)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(OpenClawChatTheme.assistantBubble))
|
||||
|
||||
ForEach(self.prompts) { prompt in
|
||||
Button {
|
||||
self.onPrompt(prompt)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Text(prompt.title)
|
||||
.font(OpenClawChatTypography.body(size: 15, weight: .semibold, relativeTo: .callout))
|
||||
.multilineTextAlignment(.leading)
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(OpenClawChatTypography.captionSemiBold)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(OpenClawChatTheme.subtleCard))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("chat-starter-\(prompt.id)")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 340, alignment: .leading)
|
||||
.padding(.top, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatLoadingBubble: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Loading chat")
|
||||
.font(OpenClawChatTypography.captionSemiBold)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 9)
|
||||
.padding(.horizontal, 12)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(OpenClawChatTheme.subtleCard))
|
||||
.padding(.leading, 10)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatNoticeCard: View {
|
||||
let systemImage: String
|
||||
let title: String
|
||||
let message: String
|
||||
let actionTitle: String?
|
||||
let action: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
// Native empty/error state: SwiftUI's standard ContentUnavailableView, not a custom card.
|
||||
ContentUnavailableView {
|
||||
Label(self.title, systemImage: self.systemImage)
|
||||
.font(OpenClawChatTypography.headline)
|
||||
} description: {
|
||||
Text(self.message)
|
||||
.font(OpenClawChatTypography.body)
|
||||
} actions: {
|
||||
if let actionTitle, let action {
|
||||
Button(action: action) {
|
||||
Text(actionTitle)
|
||||
.font(OpenClawChatTypography.body(size: 15, weight: .semibold, relativeTo: .subheadline))
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(OpenClawChatTypography.display(size: 15, weight: .semibold, relativeTo: .subheadline))
|
||||
.foregroundStyle(self.tint)
|
||||
.padding(.top, 1)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.title)
|
||||
.font(OpenClawChatTypography.captionSemiBold)
|
||||
|
||||
Text(self.message)
|
||||
.font(OpenClawChatTypography.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(OpenClawChatTheme.subtleCard)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user