Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatProgressCard.swift
T
Peter Steinberger 60920998c0 feat(apps): migrate iOS/macOS plan surface to the durable progress card (#125442)
* feat(apps): migrate iOS/macOS plan surface to the durable progress card

Replace the legacy stream:"plan" agent-event pipeline (runId-scoped state,
run-gated pill) with the sessionKey-scoped progress-card store: the shared
chat surface now renders progressCard.get snapshots, refetches on
progressCard.changed pokes with revision dedupe, clears on null-revision
pokes, and persists the card after the run completes. The card renders
markdown through the shared markdown view plus typed steps. Legacy Apple-side
plan handling (agent-event case, run-snapshot plan reconciliation,
OpenClawChatPlanStep parsing) is deleted; gateway emission stays for Android.
Removes the ios progressCard.changed coverage allowlist entry so the check
enforces the handler.

* chore(i18n): refresh native inventory for the progress-card rename

* fix(apps): keep the last progress card when a refresh fails

A transient progressCard.get failure no longer clears an already-rendered
durable card; only a successful null fetch or a null-revision poke clears it.
2026-08-17 18:07:17 -07:00

191 lines
7.1 KiB
Swift

import Foundation
import OpenClawProtocol
import SwiftUI
private struct ChatProgressCardSurface: 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 ChatProgressCard: View {
let steps: [ProgressCardStep]
let markdown: String?
@State private var isExpanded = false
private var completedCount: Int {
self.steps.count { $0.status == .completed }
}
private var currentStep: ProgressCardStep? {
self.steps.first { $0.status == .inProgress }
?? self.steps.last { $0.status == .completed }
?? self.steps.first
}
private var markdownSummary: String? {
guard let line = self.markdown?
.split(whereSeparator: \.isNewline)
.map(String.init)
.first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
else { return nil }
var summary = line.trimmingCharacters(in: .whitespacesAndNewlines)
while let first = summary.first,
first.isWhitespace || "#-*>".contains(first)
{
summary.removeFirst()
}
return summary.trimmingCharacters(in: .whitespacesAndNewlines)
}
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 markdown {
OpenClawChatMarkdownView(
text: markdown,
isUserMessage: false,
variant: .compact,
textColor: OpenClawChatTheme.assistantText)
}
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(ChatProgressCardSurface(cornerRadius: self.isExpanded ? 16 : 18))
.foregroundStyle(OpenClawChatTheme.assistantText)
}
private var summaryAccessibilityLabel: String {
if let currentStep {
return "Plan, \(self.completedCount) of \(self.steps.count) steps done, "
+ "\(Self.accessibilityLabel(for: currentStep.status)): \(currentStep.step)"
}
return "Plan, \(self.markdownSummary ?? "Progress update")"
}
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)
} else if let markdownSummary {
Text(markdownSummary)
.font(OpenClawChatTypography.footnoteSemiBold)
.lineLimit(1)
.truncationMode(.tail)
}
Spacer(minLength: 8)
if !self.steps.isEmpty {
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: ProgressCardStep) -> 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: ProgressCardStepStatus) -> String {
switch status {
case .completed: "✓"
case .inProgress: "▸"
case .pending: "▢"
}
}
private static func markerColor(for status: ProgressCardStepStatus) -> Color {
switch status {
case .completed, .inProgress: OpenClawChatTheme.accent
case .pending: OpenClawChatTheme.muted
}
}
private static func stepAccessibilityLabel(_ step: ProgressCardStep) -> String {
"\(self.accessibilityLabel(for: step.status)), \(step.step)"
}
private static func accessibilityLabel(for status: ProgressCardStepStatus) -> String {
switch status {
case .completed: "Completed"
case .inProgress: "In progress"
case .pending: "Pending"
}
}
}