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:
@@ -0,0 +1,807 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
private enum ChatUIConstants {
|
||||
static let bubbleMaxWidth: CGFloat = 560
|
||||
static let bubbleCorner: CGFloat = 18
|
||||
}
|
||||
|
||||
struct ChatAgentAvatar: View {
|
||||
let text: String?
|
||||
let name: String?
|
||||
let tint: Color?
|
||||
var size: CGFloat = 30
|
||||
|
||||
var body: some View {
|
||||
Text(self.displayText)
|
||||
.font(OpenClawChatTypography.avatar(size: self.fontSize))
|
||||
.foregroundStyle(.white)
|
||||
.minimumScaleFactor(0.6)
|
||||
.lineLimit(1)
|
||||
.frame(width: self.size, height: self.size)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
(self.tint ?? OpenClawChatTheme.accent).opacity(0.95),
|
||||
Color(red: 38 / 255.0, green: 40 / 255.0, blue: 43 / 255.0),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)))
|
||||
.overlay(
|
||||
Circle()
|
||||
.strokeBorder(Color.white.opacity(0.18), lineWidth: 1))
|
||||
.shadow(color: (self.tint ?? OpenClawChatTheme.accent).opacity(0.18), radius: 8, y: 4)
|
||||
.accessibilityLabel(self.name.map { "\($0) avatar" } ?? "Agent avatar")
|
||||
}
|
||||
|
||||
private var displayText: String {
|
||||
if let text = self.text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
|
||||
return String(text.prefix(3))
|
||||
}
|
||||
if let name = self.name?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
|
||||
let words = name.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" }).prefix(2)
|
||||
let initials = words.compactMap(\.first).map(String.init).joined()
|
||||
if !initials.isEmpty {
|
||||
return initials.uppercased()
|
||||
}
|
||||
}
|
||||
return "OC"
|
||||
}
|
||||
|
||||
private var fontSize: CGFloat {
|
||||
self.displayText.count > 2 ? self.size * 0.34 : self.size * 0.42
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatBubbleShape: InsettableShape {
|
||||
enum Tail {
|
||||
case left
|
||||
case right
|
||||
case none
|
||||
}
|
||||
|
||||
let cornerRadius: CGFloat
|
||||
let tail: Tail
|
||||
var insetAmount: CGFloat = 0
|
||||
|
||||
private let tailWidth: CGFloat = 7
|
||||
private let tailBaseHeight: CGFloat = 9
|
||||
|
||||
func inset(by amount: CGFloat) -> ChatBubbleShape {
|
||||
var copy = self
|
||||
copy.insetAmount += amount
|
||||
return copy
|
||||
}
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let rect = rect.insetBy(dx: self.insetAmount, dy: self.insetAmount)
|
||||
switch self.tail {
|
||||
case .left:
|
||||
return self.leftTailPath(in: rect, radius: self.cornerRadius)
|
||||
case .right:
|
||||
return self.rightTailPath(in: rect, radius: self.cornerRadius)
|
||||
case .none:
|
||||
return Path(roundedRect: rect, cornerRadius: self.cornerRadius)
|
||||
}
|
||||
}
|
||||
|
||||
private func rightTailPath(in rect: CGRect, radius r: CGFloat) -> Path {
|
||||
var path = Path()
|
||||
let bubbleMinX = rect.minX
|
||||
let bubbleMaxX = rect.maxX - self.tailWidth
|
||||
let bubbleMinY = rect.minY
|
||||
let bubbleMaxY = rect.maxY
|
||||
|
||||
let available = max(4, bubbleMaxY - bubbleMinY - 2 * r)
|
||||
let baseH = min(tailBaseHeight, available)
|
||||
let baseBottomY = bubbleMaxY - max(r * 0.45, 6)
|
||||
let baseTopY = baseBottomY - baseH
|
||||
let midY = (baseTopY + baseBottomY) / 2
|
||||
|
||||
let baseTop = CGPoint(x: bubbleMaxX, y: baseTopY)
|
||||
let baseBottom = CGPoint(x: bubbleMaxX, y: baseBottomY)
|
||||
let tip = CGPoint(x: bubbleMaxX + self.tailWidth, y: midY)
|
||||
|
||||
path.move(to: CGPoint(x: bubbleMinX + r, y: bubbleMinY))
|
||||
path.addLine(to: CGPoint(x: bubbleMaxX - r, y: bubbleMinY))
|
||||
path.addQuadCurve(
|
||||
to: CGPoint(x: bubbleMaxX, y: bubbleMinY + r),
|
||||
control: CGPoint(x: bubbleMaxX, y: bubbleMinY))
|
||||
path.addLine(to: baseTop)
|
||||
path.addCurve(
|
||||
to: tip,
|
||||
control1: CGPoint(x: bubbleMaxX + self.tailWidth * 0.2, y: baseTopY + baseH * 0.05),
|
||||
control2: CGPoint(x: bubbleMaxX + self.tailWidth * 0.95, y: midY - baseH * 0.15))
|
||||
path.addCurve(
|
||||
to: baseBottom,
|
||||
control1: CGPoint(x: bubbleMaxX + self.tailWidth * 0.95, y: midY + baseH * 0.15),
|
||||
control2: CGPoint(x: bubbleMaxX + self.tailWidth * 0.2, y: baseBottomY - baseH * 0.05))
|
||||
self.addBottomEdge(
|
||||
path: &path,
|
||||
bubbleMinX: bubbleMinX,
|
||||
bubbleMaxX: bubbleMaxX,
|
||||
bubbleMaxY: bubbleMaxY,
|
||||
radius: r)
|
||||
path.addLine(to: CGPoint(x: bubbleMinX, y: bubbleMinY + r))
|
||||
path.addQuadCurve(
|
||||
to: CGPoint(x: bubbleMinX + r, y: bubbleMinY),
|
||||
control: CGPoint(x: bubbleMinX, y: bubbleMinY))
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
private func leftTailPath(in rect: CGRect, radius r: CGFloat) -> Path {
|
||||
var path = Path()
|
||||
let bubbleMinX = rect.minX + self.tailWidth
|
||||
let bubbleMaxX = rect.maxX
|
||||
let bubbleMinY = rect.minY
|
||||
let bubbleMaxY = rect.maxY
|
||||
|
||||
let available = max(4, bubbleMaxY - bubbleMinY - 2 * r)
|
||||
let baseH = min(tailBaseHeight, available)
|
||||
let baseBottomY = bubbleMaxY - max(r * 0.45, 6)
|
||||
let baseTopY = baseBottomY - baseH
|
||||
let midY = (baseTopY + baseBottomY) / 2
|
||||
|
||||
let baseTop = CGPoint(x: bubbleMinX, y: baseTopY)
|
||||
let baseBottom = CGPoint(x: bubbleMinX, y: baseBottomY)
|
||||
let tip = CGPoint(x: bubbleMinX - self.tailWidth, y: midY)
|
||||
|
||||
path.move(to: CGPoint(x: bubbleMinX + r, y: bubbleMinY))
|
||||
path.addLine(to: CGPoint(x: bubbleMaxX - r, y: bubbleMinY))
|
||||
path.addQuadCurve(
|
||||
to: CGPoint(x: bubbleMaxX, y: bubbleMinY + r),
|
||||
control: CGPoint(x: bubbleMaxX, y: bubbleMinY))
|
||||
path.addLine(to: CGPoint(x: bubbleMaxX, y: bubbleMaxY - r))
|
||||
self.addBottomEdge(
|
||||
path: &path,
|
||||
bubbleMinX: bubbleMinX,
|
||||
bubbleMaxX: bubbleMaxX,
|
||||
bubbleMaxY: bubbleMaxY,
|
||||
radius: r)
|
||||
path.addLine(to: baseBottom)
|
||||
path.addCurve(
|
||||
to: tip,
|
||||
control1: CGPoint(x: bubbleMinX - self.tailWidth * 0.2, y: baseBottomY - baseH * 0.05),
|
||||
control2: CGPoint(x: bubbleMinX - self.tailWidth * 0.95, y: midY + baseH * 0.15))
|
||||
path.addCurve(
|
||||
to: baseTop,
|
||||
control1: CGPoint(x: bubbleMinX - self.tailWidth * 0.95, y: midY - baseH * 0.15),
|
||||
control2: CGPoint(x: bubbleMinX - self.tailWidth * 0.2, y: baseTopY + baseH * 0.05))
|
||||
path.addLine(to: CGPoint(x: bubbleMinX, y: bubbleMinY + r))
|
||||
path.addQuadCurve(
|
||||
to: CGPoint(x: bubbleMinX + r, y: bubbleMinY),
|
||||
control: CGPoint(x: bubbleMinX, y: bubbleMinY))
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
private func addBottomEdge(
|
||||
path: inout Path,
|
||||
bubbleMinX: CGFloat,
|
||||
bubbleMaxX: CGFloat,
|
||||
bubbleMaxY: CGFloat,
|
||||
radius: CGFloat)
|
||||
{
|
||||
path.addQuadCurve(
|
||||
to: CGPoint(x: bubbleMaxX - radius, y: bubbleMaxY),
|
||||
control: CGPoint(x: bubbleMaxX, y: bubbleMaxY))
|
||||
path.addLine(to: CGPoint(x: bubbleMinX + radius, y: bubbleMaxY))
|
||||
path.addQuadCurve(
|
||||
to: CGPoint(x: bubbleMinX, y: bubbleMaxY - radius),
|
||||
control: CGPoint(x: bubbleMinX, y: bubbleMaxY))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ChatMessageBubble: View {
|
||||
let message: OpenClawChatMessage
|
||||
let style: OpenClawChatView.Style
|
||||
let markdownVariant: ChatMarkdownVariant
|
||||
let userAccent: Color?
|
||||
let showsAssistantTrace: Bool
|
||||
let assistantName: String?
|
||||
let assistantAvatarText: String?
|
||||
let assistantAvatarTint: Color?
|
||||
let showsAssistantAvatar: Bool
|
||||
let isClean: Bool
|
||||
|
||||
var body: some View {
|
||||
if self.isUser {
|
||||
self.messageBody
|
||||
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .trailing)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
.padding(.horizontal, 2)
|
||||
} else {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
if self.showsAssistantAvatar {
|
||||
ChatAgentAvatar(
|
||||
text: self.assistantAvatarText,
|
||||
name: self.assistantName,
|
||||
tint: self.assistantAvatarTint)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
|
||||
self.messageBody
|
||||
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
}
|
||||
|
||||
private var isUser: Bool {
|
||||
self.message.role.lowercased() == "user"
|
||||
}
|
||||
|
||||
private var messageBody: some View {
|
||||
ChatMessageBody(
|
||||
message: self.message,
|
||||
isUser: self.isUser,
|
||||
style: self.style,
|
||||
markdownVariant: self.markdownVariant,
|
||||
userAccent: self.userAccent,
|
||||
showsAssistantTrace: self.showsAssistantTrace,
|
||||
isClean: self.isClean)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct ChatMessageBody: View {
|
||||
@Environment(\.openClawAssistantBubblesInCleanChrome) private var assistantBubblesInClean
|
||||
let message: OpenClawChatMessage
|
||||
let isUser: Bool
|
||||
let style: OpenClawChatView.Style
|
||||
let markdownVariant: ChatMarkdownVariant
|
||||
let userAccent: Color?
|
||||
let showsAssistantTrace: Bool
|
||||
let isClean: Bool
|
||||
|
||||
var body: some View {
|
||||
let text = self.primaryText
|
||||
let textColor = self.isUser ? OpenClawChatTheme.userText : OpenClawChatTheme.assistantText
|
||||
|
||||
if self.usesBubble {
|
||||
self.messageContent(text: text, textColor: textColor)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 12)
|
||||
.background(self.bubbleBackground)
|
||||
.clipShape(self.bubbleShape)
|
||||
.overlay(self.bubbleBorder)
|
||||
.shadow(
|
||||
color: self.bubbleShadowColor,
|
||||
radius: self.bubbleShadowRadius,
|
||||
y: self.bubbleShadowYOffset)
|
||||
.padding(.leading, self.tailPaddingLeading)
|
||||
.padding(.trailing, self.tailPaddingTrailing)
|
||||
} else {
|
||||
self.messageContent(text: text, textColor: textColor)
|
||||
.padding(.vertical, 5)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private func messageContent(text: String, textColor: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if self.isToolResultMessage, self.showsAssistantTrace {
|
||||
if !text.isEmpty {
|
||||
ToolResultCard(
|
||||
title: self.toolResultTitle,
|
||||
text: text,
|
||||
isUser: self.isUser,
|
||||
toolName: self.message.toolName)
|
||||
}
|
||||
} else if self.isUser {
|
||||
ChatMarkdownRenderer(
|
||||
text: text,
|
||||
context: .user,
|
||||
variant: self.markdownVariant,
|
||||
font: OpenClawChatTypography.body,
|
||||
textColor: textColor)
|
||||
} else {
|
||||
ChatAssistantTextBody(
|
||||
text: text,
|
||||
markdownVariant: self.markdownVariant,
|
||||
includesThinking: self.showsAssistantTrace)
|
||||
}
|
||||
|
||||
if !self.inlineAttachments.isEmpty {
|
||||
ForEach(self.inlineAttachments.indices, id: \.self) { idx in
|
||||
AttachmentRow(att: self.inlineAttachments[idx], isUser: self.isUser)
|
||||
}
|
||||
}
|
||||
|
||||
if self.showsAssistantTrace, !self.toolCalls.isEmpty {
|
||||
ForEach(self.toolCalls.indices, id: \.self) { idx in
|
||||
ToolCallCard(
|
||||
content: self.toolCalls[idx],
|
||||
isUser: self.isUser)
|
||||
}
|
||||
}
|
||||
|
||||
if self.showsAssistantTrace, !self.inlineToolResults.isEmpty {
|
||||
ForEach(self.inlineToolResults.indices, id: \.self) { idx in
|
||||
let toolResult = self.inlineToolResults[idx]
|
||||
let display = ToolDisplayRegistry.resolve(name: toolResult.name ?? "tool", args: nil)
|
||||
ToolResultCard(
|
||||
title: "\(display.emoji) \(display.title)",
|
||||
text: toolResult.text ?? "",
|
||||
isUser: self.isUser,
|
||||
toolName: toolResult.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
.textSelection(.enabled)
|
||||
.foregroundStyle(textColor)
|
||||
}
|
||||
|
||||
private var usesBubble: Bool {
|
||||
// Keep the guarded base condition; iOS additionally opts assistant
|
||||
// messages into bubbles via the clean-chrome environment flag.
|
||||
self.isUser || self.style == .onboarding || !self.isClean || self.assistantBubblesInClean
|
||||
}
|
||||
|
||||
private var primaryText: String {
|
||||
let parts = self.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: self.message.role,
|
||||
stopReason: self.message.stopReason,
|
||||
errorMessage: self.message.errorMessage)
|
||||
}
|
||||
|
||||
private var inlineAttachments: [OpenClawChatMessageContent] {
|
||||
self.message.content.filter { content in
|
||||
switch content.type ?? "text" {
|
||||
case "file", "attachment":
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var toolCalls: [OpenClawChatMessageContent] {
|
||||
self.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 var inlineToolResults: [OpenClawChatMessageContent] {
|
||||
self.message.content.filter { content in
|
||||
let kind = (content.type ?? "").lowercased()
|
||||
return kind == "toolresult" || kind == "tool_result"
|
||||
}
|
||||
}
|
||||
|
||||
private var isToolResultMessage: Bool {
|
||||
let role = self.message.role.lowercased()
|
||||
return role == "toolresult" || role == "tool_result"
|
||||
}
|
||||
|
||||
private var toolResultTitle: String {
|
||||
if let name = self.message.toolName, !name.isEmpty {
|
||||
let display = ToolDisplayRegistry.resolve(name: name, args: nil)
|
||||
return "\(display.emoji) \(display.title)"
|
||||
}
|
||||
let display = ToolDisplayRegistry.resolve(name: "tool", args: nil)
|
||||
return "\(display.emoji) \(display.title)"
|
||||
}
|
||||
|
||||
private var bubbleFillColor: Color {
|
||||
if self.isUser {
|
||||
return self.userAccent ?? OpenClawChatTheme.userBubble
|
||||
}
|
||||
if self.style == .onboarding {
|
||||
return OpenClawChatTheme.onboardingAssistantBubble
|
||||
}
|
||||
return OpenClawChatTheme.assistantBubble
|
||||
}
|
||||
|
||||
private var bubbleBackground: AnyShapeStyle {
|
||||
AnyShapeStyle(self.bubbleFillColor)
|
||||
}
|
||||
|
||||
private var bubbleBorderColor: Color {
|
||||
if self.isUser {
|
||||
return Color.white.opacity(0.12)
|
||||
}
|
||||
if self.style == .onboarding {
|
||||
return OpenClawChatTheme.onboardingAssistantBorder
|
||||
}
|
||||
return Color.white.opacity(0.08)
|
||||
}
|
||||
|
||||
private var bubbleBorderWidth: CGFloat {
|
||||
if self.isUser { return 0.5 }
|
||||
if self.style == .onboarding { return 0.8 }
|
||||
return 1
|
||||
}
|
||||
|
||||
private var bubbleBorder: some View {
|
||||
self.bubbleShape.strokeBorder(self.bubbleBorderColor, lineWidth: self.bubbleBorderWidth)
|
||||
}
|
||||
|
||||
private var bubbleShape: ChatBubbleShape {
|
||||
ChatBubbleShape(cornerRadius: ChatUIConstants.bubbleCorner, tail: self.bubbleTail)
|
||||
}
|
||||
|
||||
private var bubbleTail: ChatBubbleShape.Tail {
|
||||
guard self.style == .onboarding else { return .none }
|
||||
return self.isUser ? .right : .left
|
||||
}
|
||||
|
||||
private var tailPaddingLeading: CGFloat {
|
||||
self.style == .onboarding && !self.isUser ? 8 : 0
|
||||
}
|
||||
|
||||
private var tailPaddingTrailing: CGFloat {
|
||||
self.style == .onboarding && self.isUser ? 8 : 0
|
||||
}
|
||||
|
||||
private var bubbleShadowColor: Color {
|
||||
self.style == .onboarding && !self.isUser ? Color.black.opacity(0.28) : .clear
|
||||
}
|
||||
|
||||
private var bubbleShadowRadius: CGFloat {
|
||||
self.style == .onboarding && !self.isUser ? 6 : 0
|
||||
}
|
||||
|
||||
private var bubbleShadowYOffset: CGFloat {
|
||||
self.style == .onboarding && !self.isUser ? 2 : 0
|
||||
}
|
||||
}
|
||||
|
||||
private struct AttachmentRow: View {
|
||||
let att: OpenClawChatMessageContent
|
||||
let isUser: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "paperclip")
|
||||
Text(self.att.fileName ?? "Attachment")
|
||||
.font(OpenClawChatTypography.footnote)
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(self.isUser ? OpenClawChatTheme.userText : OpenClawChatTheme.assistantText)
|
||||
Spacer()
|
||||
}
|
||||
.padding(10)
|
||||
.background(self.isUser ? Color.white.opacity(0.2) : Color.black.opacity(0.04))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolCallCard: View {
|
||||
let content: OpenClawChatMessageContent
|
||||
let isUser: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
Text(self.toolName)
|
||||
.font(OpenClawChatTypography.footnoteSemiBold)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
if let summary = self.summary, !summary.isEmpty {
|
||||
Text(summary)
|
||||
.font(OpenClawChatTypography.mono(size: 13, relativeTo: .footnote))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(OpenClawChatTheme.subtleCard)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1)))
|
||||
}
|
||||
|
||||
private var toolName: String {
|
||||
"\(self.display.emoji) \(self.display.title)"
|
||||
}
|
||||
|
||||
private var summary: String? {
|
||||
self.display.detailLine
|
||||
}
|
||||
|
||||
private var display: ToolDisplaySummary {
|
||||
ToolDisplayRegistry.resolve(name: self.content.name ?? "tool", args: self.content.arguments)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolResultCard: View {
|
||||
let title: String
|
||||
let text: String
|
||||
let isUser: Bool
|
||||
let toolName: String?
|
||||
@State private var expanded = false
|
||||
|
||||
var body: some View {
|
||||
if !self.displayContent.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Text(self.title)
|
||||
.font(OpenClawChatTypography.footnoteSemiBold)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
Text(self.displayText)
|
||||
.font(OpenClawChatTypography.mono(size: 13, relativeTo: .footnote))
|
||||
.foregroundStyle(self.isUser ? OpenClawChatTheme.userText : OpenClawChatTheme.assistantText)
|
||||
.lineLimit(self.expanded ? nil : Self.previewLineLimit)
|
||||
|
||||
if self.shouldShowToggle {
|
||||
Button(self.expanded ? "Show less" : "Show full output") {
|
||||
self.expanded.toggle()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(OpenClawChatTypography.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(OpenClawChatTheme.subtleCard)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
private static let previewLineLimit = 8
|
||||
|
||||
private var displayContent: String {
|
||||
ToolResultTextFormatter.format(text: self.text, toolName: self.toolName)
|
||||
}
|
||||
|
||||
private var lines: [Substring] {
|
||||
self.displayContent.components(separatedBy: .newlines).map { Substring($0) }
|
||||
}
|
||||
|
||||
private var displayText: String {
|
||||
guard !self.expanded, self.lines.count > Self.previewLineLimit else { return self.displayContent }
|
||||
return self.lines.prefix(Self.previewLineLimit).joined(separator: "\n") + "\n…"
|
||||
}
|
||||
|
||||
private var shouldShowToggle: Bool {
|
||||
self.lines.count > Self.previewLineLimit
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ChatTypingIndicatorBubble: View {
|
||||
let style: OpenClawChatView.Style
|
||||
let assistantName: String?
|
||||
let assistantAvatarText: String?
|
||||
let assistantAvatarTint: Color?
|
||||
let showsAssistantAvatar: Bool
|
||||
let isClean: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
if self.showsAssistantAvatar {
|
||||
ChatAgentAvatar(
|
||||
text: self.assistantAvatarText,
|
||||
name: self.assistantName,
|
||||
tint: self.assistantAvatarTint,
|
||||
size: 28)
|
||||
}
|
||||
|
||||
HStack(spacing: 9) {
|
||||
TypingDots()
|
||||
Text("Writing")
|
||||
.font(OpenClawChatTypography.captionSemiBold)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, self.isClean ? 5 : (self.style == .standard ? 10 : 9))
|
||||
.padding(.horizontal, self.isClean ? 4 : (self.style == .standard ? 12 : 14))
|
||||
.assistantBubbleContainerStyle(isClean: self.isClean, cornerRadius: 15)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.focusable(false)
|
||||
}
|
||||
}
|
||||
|
||||
extension ChatTypingIndicatorBubble: @MainActor Equatable {
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.style == rhs.style &&
|
||||
lhs.assistantName == rhs.assistantName &&
|
||||
lhs.assistantAvatarText == rhs.assistantAvatarText &&
|
||||
lhs.showsAssistantAvatar == rhs.showsAssistantAvatar &&
|
||||
lhs.isClean == rhs.isClean
|
||||
}
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// Clients that want iMessage-style assistant bubbles in the clean chrome
|
||||
/// (the iOS app) opt in; the default keeps the plain clean look elsewhere.
|
||||
@Entry public var openClawAssistantBubblesInCleanChrome: Bool = false
|
||||
}
|
||||
|
||||
private struct AssistantBubbleContainerStyle: ViewModifier {
|
||||
let isClean: Bool
|
||||
let cornerRadius: CGFloat
|
||||
|
||||
@Environment(\.openClawAssistantBubblesInCleanChrome) private var bubblesInClean
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if self.isClean, !self.bubblesInClean {
|
||||
content
|
||||
} else {
|
||||
content
|
||||
// Clean call sites pre-pad only ~4pt; bubbles need room to breathe.
|
||||
.padding(self.isClean ? 8 : 0)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous)
|
||||
.fill(OpenClawChatTheme.assistantBubble))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
fileprivate func assistantBubbleContainerStyle(isClean: Bool, cornerRadius: CGFloat = 16) -> some View {
|
||||
self.modifier(AssistantBubbleContainerStyle(isClean: isClean, cornerRadius: cornerRadius))
|
||||
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
|
||||
.focusable(false)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ChatStreamingAssistantBubble: View {
|
||||
let text: String
|
||||
let markdownVariant: ChatMarkdownVariant
|
||||
let showsAssistantTrace: Bool
|
||||
let assistantName: String?
|
||||
let assistantAvatarText: String?
|
||||
let assistantAvatarTint: Color?
|
||||
let showsAssistantAvatar: Bool
|
||||
let isClean: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
if self.showsAssistantAvatar {
|
||||
ChatAgentAvatar(
|
||||
text: self.assistantAvatarText,
|
||||
name: self.assistantName,
|
||||
tint: self.assistantAvatarTint)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ChatAssistantTextBody(
|
||||
text: self.text,
|
||||
markdownVariant: self.markdownVariant,
|
||||
includesThinking: self.showsAssistantTrace)
|
||||
}
|
||||
.padding(self.isClean ? 4 : 12)
|
||||
.assistantBubbleContainerStyle(isClean: self.isClean)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ChatPendingToolsBubble: View {
|
||||
let toolCalls: [OpenClawChatPendingToolCall]
|
||||
let isClean: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Running tools…", systemImage: "hammer")
|
||||
.font(OpenClawChatTypography.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
ForEach(self.toolCalls) { call in
|
||||
let display = ToolDisplayRegistry.resolve(name: call.name, args: call.args)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text("\(display.emoji) \(display.label)")
|
||||
.font(OpenClawChatTypography.mono(size: 13, relativeTo: .footnote))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
ProgressView().controlSize(.mini)
|
||||
}
|
||||
if let detail = display.detailLine, !detail.isEmpty {
|
||||
Text(detail)
|
||||
.font(OpenClawChatTypography.mono(size: 12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color.white.opacity(0.06))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
}
|
||||
.padding(self.isClean ? 4 : 12)
|
||||
.assistantBubbleContainerStyle(isClean: self.isClean)
|
||||
}
|
||||
}
|
||||
|
||||
extension ChatPendingToolsBubble: @MainActor Equatable {
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.toolCalls == rhs.toolCalls && lhs.isClean == rhs.isClean
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct TypingDots: View {
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var animate = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 5) {
|
||||
ForEach(0..<3, id: \.self) { idx in
|
||||
Circle()
|
||||
.fill(Color.secondary.opacity(0.55))
|
||||
.frame(width: 7, height: 7)
|
||||
.scaleEffect(self.reduceMotion ? 0.85 : (self.animate ? 1.05 : 0.70))
|
||||
.opacity(self.reduceMotion ? 0.55 : (self.animate ? 0.95 : 0.30))
|
||||
.animation(
|
||||
self.reduceMotion ? nil : .easeInOut(duration: 0.55)
|
||||
.repeatForever(autoreverses: true)
|
||||
.delay(Double(idx) * 0.16),
|
||||
value: self.animate)
|
||||
}
|
||||
}
|
||||
.onAppear { self.updateAnimationState() }
|
||||
.onDisappear { self.animate = false }
|
||||
.onChange(of: self.scenePhase) { _, _ in
|
||||
self.updateAnimationState()
|
||||
}
|
||||
.onChange(of: self.reduceMotion) { _, _ in
|
||||
self.updateAnimationState()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateAnimationState() {
|
||||
guard !self.reduceMotion, self.scenePhase == .active else {
|
||||
self.animate = false
|
||||
return
|
||||
}
|
||||
self.animate = true
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatAssistantTextBody: View {
|
||||
let text: String
|
||||
let markdownVariant: ChatMarkdownVariant
|
||||
let includesThinking: Bool
|
||||
|
||||
var body: some View {
|
||||
let segments = AssistantTextParser.segments(from: self.text, includeThinking: self.includesThinking)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(segments) { segment in
|
||||
let font = segment.kind == .thinking
|
||||
? OpenClawChatTypography.callout.italic()
|
||||
: OpenClawChatTypography.body
|
||||
ChatMarkdownRenderer(
|
||||
text: segment.text,
|
||||
context: .assistant,
|
||||
variant: self.markdownVariant,
|
||||
font: font,
|
||||
textColor: OpenClawChatTheme.assistantText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user