mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-24 07:21:20 +00:00
The gateway already broadcasts stream:"plan" agent events with typed
{step, status} snapshots; all three clients dropped them. Control UI now
renders an expandable checklist bar above the composer (run-scoped,
cleared on run lifecycle); the shared iOS/macOS chat UI mounts a
Liquid Glass pill (iOS 26 glassEffect, material fallback) between the
message list and composer; Android adds an expandable pill above the
composer bridged through ChatController/NodeRuntime/MainViewModel.
All parsers accept typed and legacy string steps, enforce the
single-in_progress invariant, and scope snapshots and clears to the
owning run. Locale artifacts, Android resources, and the iOS string
catalog carry generated translations for the pill strings.
166 lines
6.2 KiB
Swift
166 lines
6.2 KiB
Swift
import SwiftUI
|
|
|
|
private struct ChatPlanPillSurface: ViewModifier {
|
|
let cornerRadius: CGFloat
|
|
|
|
func body(content: Content) -> some View {
|
|
#if os(macOS)
|
|
content
|
|
.background(
|
|
RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous)
|
|
.fill(OpenClawChatTheme.subtleCard))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous)
|
|
.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1))
|
|
#else
|
|
if #available(iOS 26.0, *) {
|
|
content
|
|
.glassEffect(.regular, in: .rect(cornerRadius: self.cornerRadius))
|
|
} else {
|
|
content
|
|
.background(
|
|
.regularMaterial,
|
|
in: RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous)
|
|
.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1))
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
struct ChatPlanPill: View {
|
|
let steps: [OpenClawChatPlanStep]
|
|
let explanation: String?
|
|
|
|
@State private var isExpanded = false
|
|
|
|
private var completedCount: Int {
|
|
self.steps.count { $0.status == .completed }
|
|
}
|
|
|
|
private var currentStep: OpenClawChatPlanStep? {
|
|
self.steps.first { $0.status == .inProgress }
|
|
?? self.steps.last { $0.status == .completed }
|
|
?? self.steps.first
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
self.isExpanded.toggle()
|
|
}
|
|
} label: {
|
|
self.summary
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, self.isExpanded ? 11 : 9)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(self.summaryAccessibilityLabel)
|
|
.accessibilityHint(self.isExpanded ? "Collapse plan" : "Expand plan")
|
|
|
|
if self.isExpanded {
|
|
Divider()
|
|
.overlay(OpenClawChatTheme.divider)
|
|
.padding(.horizontal, 12)
|
|
VStack(alignment: .leading, spacing: 9) {
|
|
if let explanation {
|
|
Text(explanation)
|
|
.font(OpenClawChatTypography.caption)
|
|
.foregroundStyle(OpenClawChatTheme.muted)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
VStack(alignment: .leading, spacing: 7) {
|
|
ForEach(Array(self.steps.enumerated()), id: \.offset) { _, step in
|
|
self.stepRow(step)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.top, 9)
|
|
.padding(.bottom, 11)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.modifier(ChatPlanPillSurface(cornerRadius: self.isExpanded ? 16 : 18))
|
|
.foregroundStyle(OpenClawChatTheme.assistantText)
|
|
}
|
|
|
|
private var summaryAccessibilityLabel: String {
|
|
guard let currentStep else {
|
|
return "Plan, \(self.completedCount) of \(self.steps.count) steps done"
|
|
}
|
|
return "Plan, \(self.completedCount) of \(self.steps.count) steps done, "
|
|
+ "\(Self.accessibilityLabel(for: currentStep.status)): \(currentStep.step)"
|
|
}
|
|
|
|
private var summary: some View {
|
|
HStack(spacing: 8) {
|
|
if let currentStep {
|
|
Text(Self.marker(for: currentStep.status))
|
|
.font(OpenClawChatTypography.captionSemiBold)
|
|
.foregroundStyle(Self.markerColor(for: currentStep.status))
|
|
Text(currentStep.step)
|
|
.font(OpenClawChatTypography.footnoteSemiBold)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
}
|
|
Spacer(minLength: 8)
|
|
Text(verbatim: "\(self.completedCount)/\(self.steps.count)")
|
|
.font(OpenClawChatTypography.captionSemiBold)
|
|
.foregroundStyle(OpenClawChatTheme.muted)
|
|
Image(systemName: "chevron.down")
|
|
.font(OpenClawChatTypography.caption2)
|
|
.foregroundStyle(OpenClawChatTheme.muted)
|
|
.rotationEffect(.degrees(self.isExpanded ? 180 : 0))
|
|
}
|
|
}
|
|
|
|
private func stepRow(_ step: OpenClawChatPlanStep) -> some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
Text(Self.marker(for: step.status))
|
|
.font(OpenClawChatTypography.captionSemiBold)
|
|
.foregroundStyle(Self.markerColor(for: step.status))
|
|
.frame(width: 12, alignment: .center)
|
|
Text(step.step)
|
|
.font(OpenClawChatTypography.footnote)
|
|
.foregroundStyle(
|
|
step.status == .pending
|
|
? OpenClawChatTheme.muted
|
|
: OpenClawChatTheme.assistantText)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(Self.stepAccessibilityLabel(step))
|
|
}
|
|
|
|
private static func marker(for status: OpenClawChatPlanStep.Status) -> String {
|
|
switch status {
|
|
case .completed: "✓"
|
|
case .inProgress: "▸"
|
|
case .pending: "▢"
|
|
}
|
|
}
|
|
|
|
private static func markerColor(for status: OpenClawChatPlanStep.Status) -> Color {
|
|
switch status {
|
|
case .completed, .inProgress: OpenClawChatTheme.accent
|
|
case .pending: OpenClawChatTheme.muted
|
|
}
|
|
}
|
|
|
|
private static func stepAccessibilityLabel(_ step: OpenClawChatPlanStep) -> String {
|
|
"\(self.accessibilityLabel(for: step.status)), \(step.step)"
|
|
}
|
|
|
|
private static func accessibilityLabel(for status: OpenClawChatPlanStep.Status) -> String {
|
|
switch status {
|
|
case .completed: "Completed"
|
|
case .inProgress: "In progress"
|
|
case .pending: "Pending"
|
|
}
|
|
}
|
|
}
|