mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 15:21:41 +00:00
606 lines
23 KiB
Swift
606 lines
23 KiB
Swift
import SwiftUI
|
|
|
|
private let maxTrackedSwarmGroups = 10000
|
|
private let maxTrackedSwarmChildren = 100_000
|
|
private let maxRenderedSwarmDotsPerPhase = 256
|
|
private let swarmRefreshRetryDelays = [1.0, 2.0, 4.0]
|
|
|
|
struct OpenClawChatSwarmActivityState: Equatable {
|
|
private var currentPhaseByGroup: [String: String] = [:]
|
|
private var phaseRankByGroupPhase: [String: Int] = [:]
|
|
private var nextPhaseRank = 0
|
|
private var latestLogByGroup: [String: String] = [:]
|
|
private var phaseByChild: [String: String] = [:]
|
|
|
|
mutating func clear() {
|
|
self = OpenClawChatSwarmActivityState()
|
|
}
|
|
|
|
mutating func observe(_ event: OpenClawChatSessionsChangedEvent) -> Bool {
|
|
guard let groupID = Self.normalized(event.swarmGroupId) else { return false }
|
|
let kind = Self.normalized(event.kind)
|
|
let text = Self.normalized(event.text)
|
|
if let text, kind == "phase" || kind == "log" {
|
|
if kind == "phase" {
|
|
let rankKey = Self.phaseRankKey(groupID: groupID, phase: text)
|
|
if self.phaseRankByGroupPhase[rankKey] == nil {
|
|
Self.setBounded(
|
|
&self.phaseRankByGroupPhase,
|
|
key: rankKey,
|
|
value: self.nextPhaseRank,
|
|
limit: maxTrackedSwarmChildren)
|
|
self.nextPhaseRank += 1
|
|
}
|
|
Self.setBounded(
|
|
&self.currentPhaseByGroup,
|
|
key: groupID,
|
|
value: text,
|
|
limit: maxTrackedSwarmGroups)
|
|
} else {
|
|
Self.setBounded(
|
|
&self.latestLogByGroup,
|
|
key: groupID,
|
|
value: text,
|
|
limit: maxTrackedSwarmGroups)
|
|
}
|
|
return true
|
|
}
|
|
|
|
guard let childKey = Self.normalized(event.sessionKey) else { return true }
|
|
if let phase = Self.normalized(event.swarmPhase) {
|
|
Self.setBounded(
|
|
&self.phaseByChild,
|
|
key: childKey,
|
|
value: phase,
|
|
limit: maxTrackedSwarmChildren)
|
|
return true
|
|
}
|
|
if event.reason == "create",
|
|
self.phaseByChild[childKey] == nil,
|
|
let currentPhase = currentPhaseByGroup[groupID]
|
|
{
|
|
Self.setBounded(
|
|
&self.phaseByChild,
|
|
key: childKey,
|
|
value: currentPhase,
|
|
limit: maxTrackedSwarmChildren)
|
|
}
|
|
return true
|
|
}
|
|
|
|
func decorate(_ sessions: [OpenClawChatSessionEntry]) -> [OpenClawChatSessionEntry] {
|
|
sessions.map { row in
|
|
guard let groupID = Self.normalized(row.swarmGroupId) else { return row }
|
|
let phase = self.phaseByChild[row.key] ?? row.swarmPhase
|
|
let rank = phase.flatMap { self.phaseRankByGroupPhase[Self.phaseRankKey(groupID: groupID, phase: $0)] }
|
|
?? row.swarmPhaseRank
|
|
let log = self.latestLogByGroup[groupID] ?? row.swarmLog
|
|
var decorated = row
|
|
decorated.swarmPhase = phase
|
|
decorated.swarmPhaseRank = rank
|
|
decorated.swarmLog = log
|
|
return decorated
|
|
}
|
|
}
|
|
|
|
private static func normalized(_ value: String?) -> String? {
|
|
let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return normalized?.isEmpty == false ? normalized : nil
|
|
}
|
|
|
|
private static func phaseRankKey(groupID: String, phase: String) -> String {
|
|
"\(groupID)\u{0}\(phase)"
|
|
}
|
|
|
|
private static func setBounded<Value>(
|
|
_ values: inout [String: Value],
|
|
key: String,
|
|
value: Value,
|
|
limit: Int)
|
|
{
|
|
if values[key] == nil, values.count >= limit, let evicted = values.keys.first {
|
|
values.removeValue(forKey: evicted)
|
|
}
|
|
values[key] = value
|
|
}
|
|
}
|
|
|
|
enum OpenClawChatSwarmDotStatus: Int, Comparable, Sendable {
|
|
case running
|
|
case queued
|
|
case failed
|
|
case done
|
|
|
|
static func < (lhs: Self, rhs: Self) -> Bool {
|
|
lhs.rawValue < rhs.rawValue
|
|
}
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .running: String(localized: "Running")
|
|
case .queued: String(localized: "Queued")
|
|
case .failed: String(localized: "Failed")
|
|
case .done: String(localized: "Done")
|
|
}
|
|
}
|
|
}
|
|
|
|
struct OpenClawChatSwarmDot: Identifiable, Sendable {
|
|
let id: String
|
|
let label: String
|
|
let status: OpenClawChatSwarmDotStatus
|
|
}
|
|
|
|
struct OpenClawChatSwarmPhase: Identifiable, Sendable {
|
|
let id: String
|
|
let title: String?
|
|
let dots: [OpenClawChatSwarmDot]
|
|
let hidden: Int
|
|
}
|
|
|
|
struct OpenClawChatSwarmGroup: Identifiable, Sendable {
|
|
let id: String
|
|
let label: String
|
|
let running: Int
|
|
let done: Int
|
|
let failed: Int
|
|
let narrator: String?
|
|
let phases: [OpenClawChatSwarmPhase]
|
|
}
|
|
|
|
func buildOpenClawChatSwarmGroups(
|
|
sessions: [OpenClawChatSessionEntry],
|
|
matchesParent: (String) -> Bool) -> [OpenClawChatSwarmGroup]
|
|
{
|
|
struct Entry {
|
|
let phase: String?
|
|
let phaseRank: Int
|
|
let log: String?
|
|
let dot: OpenClawChatSwarmDot
|
|
}
|
|
|
|
var byGroup: [String: [Entry]] = [:]
|
|
for row in sessions {
|
|
guard let groupID = row.swarmGroupId?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!groupID.isEmpty,
|
|
SelfContainedSwarmHelpers.belongsToParent(row, groupID: groupID, matchesParent: matchesParent),
|
|
let status = SelfContainedSwarmHelpers.status(row)
|
|
else { continue }
|
|
let label = [row.label, row.displayName, row.derivedTitle, row.key]
|
|
.compactMap { value -> String? in
|
|
let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return normalized?.isEmpty == false ? normalized : nil
|
|
}
|
|
.first ?? row.key
|
|
byGroup[groupID, default: []].append(Entry(
|
|
phase: row.swarmPhase?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
phaseRank: row.swarmPhaseRank ?? .max,
|
|
log: row.swarmLog?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
dot: OpenClawChatSwarmDot(id: row.key, label: label, status: status)))
|
|
}
|
|
|
|
return byGroup.map { groupID, entries in
|
|
var phaseBuckets: [String: (title: String?, rank: Int, dots: [OpenClawChatSwarmDot])] = [:]
|
|
for entry in entries {
|
|
let phaseKey = entry.phase?.isEmpty == false ? entry.phase! : "\u{0}"
|
|
var bucket = phaseBuckets[phaseKey] ?? (entry.phase, entry.phaseRank, [])
|
|
bucket.rank = min(bucket.rank, entry.phaseRank)
|
|
bucket.dots.append(entry.dot)
|
|
phaseBuckets[phaseKey] = bucket
|
|
}
|
|
let phases = phaseBuckets.map { key, bucket -> OpenClawChatSwarmPhase in
|
|
let ordered = bucket.dots.count > maxRenderedSwarmDotsPerPhase
|
|
? bucket.dots.sorted { $0.status < $1.status }
|
|
: bucket.dots
|
|
return OpenClawChatSwarmPhase(
|
|
id: key,
|
|
title: bucket.title,
|
|
dots: Array(ordered.prefix(maxRenderedSwarmDotsPerPhase)),
|
|
hidden: max(0, ordered.count - maxRenderedSwarmDotsPerPhase))
|
|
}.sorted { lhs, rhs in
|
|
let leftRank = phaseBuckets[lhs.id]?.rank ?? .max
|
|
let rightRank = phaseBuckets[rhs.id]?.rank ?? .max
|
|
if leftRank != rightRank {
|
|
return leftRank < rightRank
|
|
}
|
|
return lhs.id < rhs.id
|
|
}
|
|
let dots = entries.map(\.dot)
|
|
return OpenClawChatSwarmGroup(
|
|
id: groupID,
|
|
label: groupID.split(separator: ":").last.map(String.init) ?? groupID,
|
|
running: dots.count { $0.status == .running },
|
|
done: dots.count { $0.status == .done },
|
|
failed: dots.count { $0.status == .failed },
|
|
narrator: entries.compactMap(\.log).first { !$0.isEmpty },
|
|
phases: phases)
|
|
}.filter { group in
|
|
group.phases.contains { phase in
|
|
phase.dots.contains { $0.status == .queued || $0.status == .running }
|
|
}
|
|
}.sorted { $0.id < $1.id }
|
|
}
|
|
|
|
enum SelfContainedSwarmHelpers {
|
|
static func status(_ row: OpenClawChatSessionEntry) -> OpenClawChatSwarmDotStatus? {
|
|
if row.status == "running" || row.hasActiveRun == true {
|
|
return .running
|
|
}
|
|
if row.status == "done" {
|
|
return .done
|
|
}
|
|
if row.status == "failed" || row.status == "killed" || row.status == "timeout" {
|
|
return .failed
|
|
}
|
|
return row.subagentRunState == "active" || row.hasActiveSubagentRun == true ? .queued : nil
|
|
}
|
|
|
|
static func belongsToParent(
|
|
_ row: OpenClawChatSessionEntry,
|
|
groupID: String,
|
|
matchesParent: (String) -> Bool) -> Bool
|
|
{
|
|
if let parent = row.parentSessionKey, matchesParent(parent) {
|
|
return true
|
|
}
|
|
if let spawnedBy = row.spawnedBy, matchesParent(spawnedBy) {
|
|
return true
|
|
}
|
|
return self.generatedGroupBelongsToParent(groupID, matchesParent: matchesParent)
|
|
}
|
|
|
|
static func isActivityNote(_ event: OpenClawChatSessionsChangedEvent) -> Bool {
|
|
let kind = self.normalized(event.kind)
|
|
return kind == "phase" || kind == "log"
|
|
}
|
|
|
|
static func eventBelongsToParent(
|
|
_ event: OpenClawChatSessionsChangedEvent,
|
|
matchesParent: (String) -> Bool) -> Bool
|
|
{
|
|
if let parent = normalized(event.parentSessionKey) ?? normalized(event.spawnedBy) {
|
|
return matchesParent(parent)
|
|
}
|
|
let kind = self.normalized(event.kind)
|
|
if kind == "phase" || kind == "log" {
|
|
guard let sessionKey = normalized(event.sessionKey) else { return false }
|
|
return matchesParent(sessionKey)
|
|
}
|
|
guard let groupID = normalized(event.swarmGroupId) else { return false }
|
|
return self.generatedGroupBelongsToParent(groupID, matchesParent: matchesParent)
|
|
}
|
|
|
|
private static func generatedGroupBelongsToParent(
|
|
_ groupID: String,
|
|
matchesParent: (String) -> Bool) -> Bool
|
|
{
|
|
let parts = groupID.split(separator: ":", omittingEmptySubsequences: false)
|
|
guard parts.first == "swarm", parts.count > 2 else { return false }
|
|
return matchesParent(parts.dropFirst().dropLast().joined(separator: ":"))
|
|
}
|
|
|
|
private static func normalized(_ value: String?) -> String? {
|
|
let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return normalized?.isEmpty == false ? normalized : nil
|
|
}
|
|
}
|
|
|
|
extension OpenClawChatViewModel {
|
|
private func updateSwarmProjection() {
|
|
self.activeSwarmGroups = buildOpenClawChatSwarmGroups(sessions: self.swarmSessions) { candidate in
|
|
self.matchesCurrentSessionKey(incoming: candidate, current: self.sessionKey)
|
|
}
|
|
}
|
|
|
|
func observeSwarmEvent(_ event: OpenClawChatSessionsChangedEvent) -> Bool {
|
|
guard swarmEnabled,
|
|
SelfContainedSwarmHelpers.eventBelongsToParent(event, matchesParent: { candidate in
|
|
self.matchesCurrentSessionKey(
|
|
incoming: candidate,
|
|
agentId: event.agentId,
|
|
current: self.sessionKey)
|
|
})
|
|
else { return false }
|
|
var nextActivity = swarmActivityState
|
|
guard nextActivity.observe(event) else { return false }
|
|
swarmActivityState = nextActivity
|
|
swarmSessions = nextActivity.decorate(swarmSessions)
|
|
self.updateSwarmProjection()
|
|
if event.kind != "phase", event.kind != "log" {
|
|
self.scheduleSwarmRefresh()
|
|
}
|
|
return true
|
|
}
|
|
|
|
func refreshSwarmCapability(
|
|
sessionSnapshot: SessionSnapshot? = nil,
|
|
retryAttempt: Int = 0) async
|
|
{
|
|
let requestedKey = sessionSnapshot?.key ?? sessionKey
|
|
guard matchesCurrentSessionKey(incoming: requestedKey, current: sessionKey) else { return }
|
|
guard let routeLease = await transport.acquireSwarmRouteLease() else {
|
|
self.scheduleSwarmCapabilityRetry(
|
|
sessionKey: requestedKey,
|
|
sessionSnapshot: sessionSnapshot,
|
|
retryAttempt: retryAttempt)
|
|
return
|
|
}
|
|
do {
|
|
let enabled = try await routeLease.isEnabled(sessionKey: requestedKey)
|
|
guard matchesCurrentSessionKey(incoming: requestedKey, current: sessionKey) else { return }
|
|
swarmEnabled = enabled
|
|
guard enabled else {
|
|
self.resetSwarmProgress()
|
|
return
|
|
}
|
|
await self.refreshSwarmSessions(
|
|
sessionKey: requestedKey,
|
|
sessionSnapshot: sessionSnapshot,
|
|
routeLease: routeLease)
|
|
} catch {
|
|
guard matchesCurrentSessionKey(incoming: requestedKey, current: sessionKey) else { return }
|
|
chatUILogger.debug("swarm capability refresh failed: \(error.localizedDescription, privacy: .public)")
|
|
self.scheduleSwarmCapabilityRetry(
|
|
sessionKey: requestedKey,
|
|
sessionSnapshot: sessionSnapshot,
|
|
retryAttempt: retryAttempt)
|
|
}
|
|
}
|
|
|
|
private func scheduleSwarmCapabilityRetry(
|
|
sessionKey requestedKey: String,
|
|
sessionSnapshot: SessionSnapshot?,
|
|
retryAttempt: Int)
|
|
{
|
|
guard matchesCurrentSessionKey(incoming: requestedKey, current: sessionKey),
|
|
swarmRefreshRetryDelays.indices.contains(retryAttempt)
|
|
else { return }
|
|
let delay = swarmRefreshRetryDelays[retryAttempt]
|
|
swarmRefreshTask?.cancel()
|
|
swarmRefreshTask = Task { [weak self] in
|
|
try? await Task.sleep(for: .seconds(delay))
|
|
guard !Task.isCancelled else { return }
|
|
await self?.refreshSwarmCapability(
|
|
sessionSnapshot: sessionSnapshot,
|
|
retryAttempt: retryAttempt + 1)
|
|
}
|
|
}
|
|
|
|
func scheduleSwarmRefresh() {
|
|
guard swarmEnabled else { return }
|
|
let requestedKey = sessionKey
|
|
swarmRefreshTask?.cancel()
|
|
swarmRefreshTask = Task { [weak self] in
|
|
try? await Task.sleep(for: .milliseconds(250))
|
|
guard !Task.isCancelled else { return }
|
|
await self?.refreshSwarmSessions(sessionKey: requestedKey)
|
|
}
|
|
}
|
|
|
|
func refreshSwarmSessions(
|
|
sessionKey requestedSessionKey: String? = nil,
|
|
sessionSnapshot: SessionSnapshot? = nil,
|
|
routeLease: OpenClawChatSwarmRouteLease? = nil,
|
|
retryAttempt: Int = 0) async
|
|
{
|
|
guard swarmEnabled else { return }
|
|
let requestedKey = requestedSessionKey ?? sessionSnapshot?.key ?? sessionKey
|
|
guard matchesCurrentSessionKey(incoming: requestedKey, current: sessionKey) else { return }
|
|
let activeRouteLease: OpenClawChatSwarmRouteLease
|
|
if let routeLease {
|
|
activeRouteLease = routeLease
|
|
} else if let capturedRouteLease = await transport.acquireSwarmRouteLease() {
|
|
guard swarmEnabled,
|
|
matchesCurrentSessionKey(incoming: requestedKey, current: sessionKey)
|
|
else { return }
|
|
do {
|
|
let enabled = try await capturedRouteLease.isEnabled(sessionKey: requestedKey)
|
|
guard swarmEnabled,
|
|
matchesCurrentSessionKey(incoming: requestedKey, current: sessionKey)
|
|
else { return }
|
|
guard enabled else {
|
|
swarmEnabled = false
|
|
self.resetSwarmProgress()
|
|
return
|
|
}
|
|
} catch {
|
|
guard swarmEnabled,
|
|
matchesCurrentSessionKey(incoming: requestedKey, current: sessionKey)
|
|
else { return }
|
|
self.scheduleSwarmSessionRetry(
|
|
sessionKey: requestedKey,
|
|
sessionSnapshot: sessionSnapshot,
|
|
retryAttempt: retryAttempt)
|
|
return
|
|
}
|
|
guard swarmEnabled,
|
|
matchesCurrentSessionKey(incoming: requestedKey, current: sessionKey)
|
|
else { return }
|
|
activeRouteLease = capturedRouteLease
|
|
} else {
|
|
guard swarmEnabled,
|
|
matchesCurrentSessionKey(incoming: requestedKey, current: sessionKey)
|
|
else { return }
|
|
self.scheduleSwarmSessionRetry(
|
|
sessionKey: requestedKey,
|
|
sessionSnapshot: sessionSnapshot,
|
|
retryAttempt: retryAttempt)
|
|
return
|
|
}
|
|
guard swarmEnabled,
|
|
matchesCurrentSessionKey(incoming: requestedKey, current: sessionKey)
|
|
else { return }
|
|
if swarmSessionKey != sessionKey {
|
|
swarmSessionKey = sessionKey
|
|
swarmActivityState.clear()
|
|
swarmSessions = []
|
|
self.updateSwarmProjection()
|
|
}
|
|
swarmRefreshGeneration &+= 1
|
|
let generation = swarmRefreshGeneration
|
|
do {
|
|
let rows = try await activeRouteLease.listChildSessions(parentKey: requestedKey)
|
|
guard generation == swarmRefreshGeneration,
|
|
swarmEnabled,
|
|
matchesCurrentSessionKey(incoming: requestedKey, current: sessionKey)
|
|
else { return }
|
|
swarmSessions = swarmActivityState.decorate(rows)
|
|
self.updateSwarmProjection()
|
|
} catch {
|
|
guard generation == swarmRefreshGeneration,
|
|
swarmEnabled,
|
|
matchesCurrentSessionKey(incoming: requestedKey, current: sessionKey)
|
|
else { return }
|
|
chatUILogger.debug("swarm child refresh failed: \(error.localizedDescription, privacy: .public)")
|
|
self.scheduleSwarmSessionRetry(
|
|
sessionKey: requestedKey,
|
|
sessionSnapshot: sessionSnapshot,
|
|
retryAttempt: retryAttempt)
|
|
}
|
|
}
|
|
|
|
private func scheduleSwarmSessionRetry(
|
|
sessionKey requestedKey: String,
|
|
sessionSnapshot: SessionSnapshot?,
|
|
retryAttempt: Int)
|
|
{
|
|
guard swarmEnabled,
|
|
matchesCurrentSessionKey(incoming: requestedKey, current: sessionKey),
|
|
swarmRefreshRetryDelays.indices.contains(retryAttempt)
|
|
else { return }
|
|
let delay = swarmRefreshRetryDelays[retryAttempt]
|
|
swarmRefreshTask?.cancel()
|
|
swarmRefreshTask = Task { [weak self] in
|
|
try? await Task.sleep(for: .seconds(delay))
|
|
guard !Task.isCancelled else { return }
|
|
await self?.refreshSwarmSessions(
|
|
sessionKey: requestedKey,
|
|
sessionSnapshot: sessionSnapshot,
|
|
retryAttempt: retryAttempt + 1)
|
|
}
|
|
}
|
|
|
|
func resetSwarmProgress() {
|
|
swarmRefreshTask?.cancel()
|
|
swarmRefreshTask = nil
|
|
swarmRefreshGeneration &+= 1
|
|
swarmSessionKey = sessionKey
|
|
swarmActivityState.clear()
|
|
swarmSessions = []
|
|
self.updateSwarmProjection()
|
|
}
|
|
}
|
|
|
|
struct OpenClawChatSwarmProgressView: View {
|
|
let groups: [OpenClawChatSwarmGroup]
|
|
|
|
var body: some View {
|
|
if !self.groups.isEmpty {
|
|
ScrollView(.vertical) {
|
|
VStack(spacing: 6) {
|
|
ForEach(self.groups) { group in
|
|
OpenClawChatSwarmGroupView(group: group)
|
|
}
|
|
}
|
|
.padding(.trailing, 2)
|
|
}
|
|
.frame(maxHeight: 260)
|
|
.accessibilityElement(children: .contain)
|
|
.accessibilityLabel(Text("Swarm"))
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct OpenClawChatSwarmGroupView: View {
|
|
let group: OpenClawChatSwarmGroup
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
Text(verbatim: self.group.label)
|
|
.font(OpenClawChatTypography.captionSemiBold)
|
|
.lineLimit(1)
|
|
Text(verbatim: String(
|
|
format: String(localized: "%1$lld Running · %2$lld Done · %3$lld Failed"),
|
|
Int64(self.group.running),
|
|
Int64(self.group.done),
|
|
Int64(self.group.failed)))
|
|
.font(OpenClawChatTypography.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
Spacer(minLength: 0)
|
|
}
|
|
if let narrator = self.group.narrator, !narrator.isEmpty {
|
|
Text(verbatim: narrator)
|
|
.font(OpenClawChatTypography.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
}
|
|
ForEach(self.group.phases) { phase in
|
|
HStack(alignment: .top, spacing: 8) {
|
|
Text(verbatim: phase.title ?? String(localized: "Unphased"))
|
|
.font(OpenClawChatTypography.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.frame(minWidth: 56, alignment: .leading)
|
|
LazyVGrid(
|
|
columns: [GridItem(.adaptive(minimum: 9, maximum: 9), spacing: 6)],
|
|
alignment: .leading,
|
|
spacing: 6)
|
|
{
|
|
ForEach(phase.dots) { dot in
|
|
OpenClawChatSwarmDotView(dot: dot)
|
|
}
|
|
if phase.hidden > 0 {
|
|
Text(verbatim: "+\(phase.hidden)")
|
|
.font(OpenClawChatTypography.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.accessibilityLabel(Text(
|
|
verbatim: phase.hidden == 1
|
|
? String(localized: "1 more worker")
|
|
: String(
|
|
format: String(localized: "%1$lld more workers"),
|
|
Int64(phase.hidden))))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 8)
|
|
.background(OpenClawChatTheme.assistantBubble.opacity(0.5), in: RoundedRectangle(cornerRadius: 10))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.stroke(OpenClawChatTheme.accent.opacity(0.2), lineWidth: 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct OpenClawChatSwarmDotView: View {
|
|
let dot: OpenClawChatSwarmDot
|
|
|
|
var body: some View {
|
|
self.shape
|
|
.frame(width: 9, height: 9)
|
|
.accessibilityElement()
|
|
.accessibilityLabel(Text(verbatim: "\(self.dot.label): \(self.dot.status.label)"))
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var shape: some View {
|
|
switch self.dot.status {
|
|
case .queued:
|
|
Circle().stroke(OpenClawChatTheme.muted, lineWidth: 1)
|
|
case .running:
|
|
Circle().fill(OpenClawChatTheme.accent)
|
|
case .done:
|
|
Circle().fill(OpenClawChatTheme.success)
|
|
case .failed:
|
|
RoundedRectangle(cornerRadius: 2)
|
|
.fill(OpenClawChatTheme.danger)
|
|
.rotationEffect(.degrees(45))
|
|
.scaleEffect(0.82)
|
|
}
|
|
}
|
|
}
|