Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Plan.swift
Peter Steinberger b3301e3212 feat(clients): show live plan checklists in web, iOS, and Android chat (#108675)
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.
2026-07-16 09:33:52 -07:00

76 lines
2.7 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"])
guard !steps.isEmpty else {
self.clearPlan(for: runId)
return
}
let explanation = (data["explanation"]?.value as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedExplanation = explanation?.isEmpty == false ? explanation : 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)
}
}