Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Plan.swift
Peter Steinberger 4bfaccafd6 feat(chat): replay the active plan snapshot to reconnecting clients (#109966)
* feat(chat): replay the active plan snapshot to reconnecting clients

Plan checklists were live-event-only: a client that connected or
refreshed mid-run saw nothing until the next update_plan call, and iOS
adoptRun actively wiped retained plan state on reconnect. The gateway
now records the latest normalized plan snapshot per run beside the
existing chat text buffers and replays it through the established
chat.history/chat.startup inFlightRun recovery contract (runId > plan >
text under the payload budget; a budget-dropped plan is sent as an
explicit empty snapshot so clients never preserve a stale checklist).

Web, iOS, and Android seed and reconcile the snapshot through one shared
contract riding each client's existing inFlightRun adoption gate: present
plan replaces, absent plan preserves (legacy gateway), a different or
terminated run clears, and stale history responses cannot clobber a newer
live plan. Verified live gap: openclaw/openclaw#108675 comment 5001475066.

* fix(ios): extract run-snapshot adoption below the file-length lint ceiling

ChatViewModel.swift crossed SwiftLint's 1500-line limit; the in-flight
snapshot adoption and plan reconciliation move to a focused extension.
Also refresh the native i18n inventory for shifted line anchors.

* fixup: regenerate i18n anchors and test formatting after rebase

* ci: retrigger after wedged queued runs

* fix(ui): use Object.hasOwn for the plan field presence probe

* fix(ios): annotate the plan-snapshot fixture init for periphery
2026-07-17 17:10:07 +01:00

84 lines
2.9 KiB
Swift

import Foundation
import OpenClawKit
/// Live plan-checklist state: full-snapshot semantics scoped to the owning run.
extension OpenClawChatViewModel {
func applyPlanSnapshot(runId: String, data: [String: AnyCodable]) {
let steps = OpenClawChatPlanStep.parseSteps(data["steps"])
let explanation = data["explanation"]?.value as? String
self.applyPlanSnapshot(runId: runId, steps: steps, explanation: explanation)
}
func applyPlanSnapshot(
runId: String,
steps: [OpenClawChatPlanStep],
explanation: String?)
{
guard !steps.isEmpty else {
self.clearPlan(for: runId)
return
}
let trimmedExplanation = explanation?.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedExplanation = trimmedExplanation?.isEmpty == false ? trimmedExplanation : nil
guard planRunId != runId ||
planSteps != steps ||
planExplanation != normalizedExplanation
else {
return
}
planRunId = runId
planSteps = steps
planExplanation = normalizedExplanation
markTimelineChanged()
}
func clearPlan(for runId: String? = nil) {
if let runId, planRunId != runId {
return
}
guard planRunId != nil || !planSteps.isEmpty || planExplanation != nil else { return }
planRunId = nil
planSteps = []
planExplanation = nil
markTimelineChanged()
}
}
/// Session-run activity indicator: shares the run-progress surface with the
/// plan checklist (both gate on an active run 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)
}
}