Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+ProgressCard.swift
T
Peter Steinberger 122fc1c786 fix(apps): render legacy plan events when the Gateway lacks the progress-card store (#125588)
* fix(apps): render legacy plan events when the Gateway lacks the progress-card store

progressCard.get ships in no released Gateway tag, so iOS/macOS on any current
release silently showed no status card after the durable-card migration. Restore
legacy stream:"plan" rendering, synthesized into the unified progress card and
gated on a tri-state capability probe so a dual-emitting Gateway never fights the
durable store. Mirrors the Android fix in #125444.

* fix(apps): invalidate progress-card capability on gateway route replacement

A replacement route may be a different Gateway, so a cached known-absent store
must not authorize the legacy plan fallback against a new Gateway that dual-emits
both sources. Clear and re-probe on .routeChanged.
2026-08-17 22:40:55 -07:00

211 lines
7.7 KiB
Swift

import Foundation
import OpenClawProtocol
extension OpenClawChatViewModel {
func handleProgressCardChanged(_ event: ProgressCardChangedEvent) {
guard self.matchesCurrentSessionKey(
incoming: event.sessionkey,
current: self.sessionKey)
else { return }
if event.revision.value is NSNull {
self.clearProgressCard()
return
}
if event.revision.value as? Int == self.progressCard?.revision {
return
}
self.scheduleProgressCardFetch()
}
func scheduleProgressCardFetch(for session: SessionSnapshot? = nil) {
let session = session ?? self.currentSessionSnapshot()
guard self.isCurrentSession(session) else { return }
Task { [weak self] in
guard let self else { return }
self.progressCardStoreAvailable = await self.transport.gatewayAdvertisesProgressCardStore()
}
self.lastIssuedProgressCardRequestID &+= 1
let requestID = self.lastIssuedProgressCardRequestID
let generation = self.progressCardGeneration
Task { [weak self] in
await self?.fetchProgressCard(
for: session,
generation: generation,
requestID: requestID)
}
}
func clearProgressCard() {
self.progressCardGeneration &+= 1
self.applyProgressCard(nil)
}
private func fetchProgressCard(
for session: SessionSnapshot,
generation: UInt64,
requestID: UInt64) async
{
do {
let card = try await self.transport.fetchProgressCard(sessionKey: session.key)
guard self.isCurrentProgressCardRequest(
session: session,
generation: generation,
requestID: requestID)
else { return }
self.applyProgressCard(card)
} catch {
guard self.isCurrentProgressCardRequest(
session: session,
generation: generation,
requestID: requestID)
else { return }
// Keep the last rendered card on transient failure: the durable
// store clears only via a successful null fetch or a null-revision
// poke, never via a failed refresh.
self.logDiagnostic(
"chat.ui progress card fetch failed sessionKey=\(session.key) "
+ "error=\(error.localizedDescription)")
}
}
private func isCurrentProgressCardRequest(
session: SessionSnapshot,
generation: UInt64,
requestID: UInt64) -> Bool
{
self.progressCardGeneration == generation &&
self.lastIssuedProgressCardRequestID == requestID &&
self.isCurrentSession(session)
}
func applyProgressCard(_ card: ProgressCard?) {
let normalized = Self.normalizedProgressCard(card)
let previousPresentation = Self.progressCardPresentation(self.progressCard)
let presentation = Self.progressCardPresentation(normalized)
let presentationChanged = previousPresentation != presentation
guard presentationChanged ||
self.progressCard?.sessionkey != normalized?.sessionkey ||
self.progressCard?.revision != normalized?.revision
else { return }
self.progressCard = normalized
if presentationChanged {
self.markTimelineChanged()
}
}
private static func normalizedProgressCard(_ card: ProgressCard?) -> ProgressCard? {
guard let card else { return nil }
let markdown = card.markdown?.trimmingCharacters(in: .whitespacesAndNewlines)
return markdown?.isEmpty == false || card.steps?.isEmpty == false ? card : nil
}
private static func progressCardPresentation(_ card: ProgressCard?) -> [String]? {
guard let card else { return nil }
return [card.markdown == nil ? "0" : "1", card.markdown ?? ""] +
(card.steps ?? []).flatMap { [$0.step, $0.status.rawValue] }
}
static func parseLegacyProgressCardSteps(_ value: AnyCodable?) -> [ProgressCardStep] {
guard let value else { return [] }
let rawItems: [Any]
switch value.value {
case let items as [AnyCodable]:
rawItems = items.map(\.value)
case let items as [Any]:
rawItems = items
case let items as NSArray:
rawItems = items.map(\.self)
default:
return []
}
var hasInProgressStep = false
return rawItems.compactMap { rawItem in
guard let step = Self.parseLegacyProgressCardStep(rawItem) else { return nil }
if case .inProgress = step.status {
guard !hasInProgressStep else { return nil }
hasInProgressStep = true
}
return step
}
}
private static func parseLegacyProgressCardStep(_ rawValue: Any) -> ProgressCardStep? {
let value = (rawValue as? AnyCodable)?.value ?? rawValue
if let legacyStep = value as? String {
return self.makeLegacyProgressCardStep(text: legacyStep, status: .pending)
}
let fields: [String: Any]
switch value {
case let dictionary as [String: AnyCodable]:
fields = dictionary.mapValues(\.value)
case let dictionary as [String: String]:
fields = dictionary
case let dictionary as [String: Any]:
fields = dictionary
case let dictionary as NSDictionary:
fields = dictionary.reduce(into: [:]) { result, entry in
guard let key = entry.key as? String else { return }
result[key] = (entry.value as? AnyCodable)?.value ?? entry.value
}
default:
return nil
}
guard let text = fields["step"] as? String,
let rawStatus = fields["status"] as? String,
let status = ProgressCardStepStatus(rawValue: rawStatus)
else {
return nil
}
return self.makeLegacyProgressCardStep(text: text, status: status)
}
private static func makeLegacyProgressCardStep(
text: String,
status: ProgressCardStepStatus) -> ProgressCardStep?
{
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return ProgressCardStep(step: trimmed, status: status)
}
}
/// Session-run activity indicator for runs without a chat snapshot.
extension OpenClawChatViewModel {
func updateActiveSessionRunWithoutChatSnapshot(_ active: Bool) {
guard self.hasActiveSessionRunWithoutChatSnapshot != active else { return }
self.hasActiveSessionRunWithoutChatSnapshot = active
if active {
self.armActiveSessionRunIndicatorTimeout()
} else {
self.activeSessionRunIndicatorTimeoutTask?.cancel()
self.activeSessionRunIndicatorTimeoutTask = nil
}
self.markTimelineChanged()
}
private func armActiveSessionRunIndicatorTimeout() {
self.activeSessionRunIndicatorTimeoutTask?.cancel()
let timeoutMs = self.pendingRunWaitTimeoutMs
self.activeSessionRunIndicatorTimeoutTask = Task { [weak self] in
do {
try await Task.sleep(nanoseconds: timeoutMs * 1_000_000)
} catch {
return
}
await MainActor.run {
self?.updateActiveSessionRunWithoutChatSnapshot(false)
}
}
}
func clearActiveSessionRunIndicatorIfLatestUserAnswered() {
guard self.hasActiveSessionRunWithoutChatSnapshot,
!Self.hasUnansweredLatestUser(in: self.messages)
else { return }
self.updateActiveSessionRunWithoutChatSnapshot(false)
}
}