fix: show iOS chat activity after foreground refresh (#102309)

* Preserve iOS chat activity on foreground refresh

Show an activity indicator when foreground history reports session activity without a chat in-flight snapshot, while keeping unanswered turns blocked and clearing the fallback state on answers, terminal events, session changes, and timeout.

* fix(ios): preserve foreground run ownership

* chore(ios): sync native string inventory

* chore: keep release changelog owned

---------

Co-authored-by: Your Name <you@example.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
John McChesney TenEyck Jr 2026-07-09 09:45:35 +01:00 committed by GitHub
parent a5f5282816
commit ccdb60e5db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 390 additions and 163 deletions

View file

@ -21875,7 +21875,7 @@
},
{
"kind": "conditional-branch",
"line": 1481,
"line": 1555,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift",
"source": "delivery unconfirmed",
"surface": "apple",
@ -21883,7 +21883,7 @@
},
{
"kind": "conditional-branch",
"line": 1481,
"line": 1555,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift",
"source": "queued after route change",
"surface": "apple",
@ -21891,7 +21891,7 @@
},
{
"kind": "ui-localized-call",
"line": 1705,
"line": 1653,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift",
"source": "Remove attachments or wait for delivery to resolve before switching chats.",
"surface": "apple",
@ -21899,7 +21899,7 @@
},
{
"kind": "ui-localized-call",
"line": 1761,
"line": 1709,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift",
"source": "Remove attachments or wait for delivery to resolve before starting a new chat.",
"surface": "apple",

View file

@ -573,7 +573,7 @@ struct OpenClawChatComposer: View {
/// draft is empty, swapping to the send button once the user types.
@ViewBuilder
private var cleanTrailingControl: some View {
if !self.viewModel.hasDraftToSend, self.viewModel.pendingRunCount == 0, let talkControl {
if !self.viewModel.hasDraftToSend, !self.viewModel.hasBlockingRunActivity, let talkControl {
self.compactTalkButton(talkControl)
} else {
self.sendButton

View file

@ -370,7 +370,7 @@ public struct OpenClawChatView: View {
self.messageRow(for: msg)
}
if self.viewModel.pendingRunCount > 0 {
if self.viewModel.hasBlockingRunActivity {
ChatTypingIndicatorBubble(
style: self.style,
assistantName: self.assistantName,
@ -657,7 +657,7 @@ public struct OpenClawChatView: View {
}
private var hasVisibleTransientContent: Bool {
self.viewModel.pendingRunCount > 0 ||
self.viewModel.hasBlockingRunActivity ||
!self.viewModel.pendingToolCalls.isEmpty ||
(self.viewModel.streamingAssistantText.map {
AssistantTextParser.hasVisibleContent(in: $0, includeThinking: self.showsAssistantTrace)
@ -716,7 +716,7 @@ public struct OpenClawChatView: View {
!(self.viewModel.streamingAssistantText.map {
AssistantTextParser.hasVisibleContent(in: $0, includeThinking: self.showsAssistantTrace)
} ?? false) &&
self.viewModel.pendingRunCount == 0 &&
!self.viewModel.hasBlockingRunActivity &&
self.viewModel.pendingToolCalls.isEmpty
}
@ -768,7 +768,7 @@ public struct OpenClawChatView: View {
private func handleTimelineChange() {
guard self.hasPerformedInitialScroll else { return }
if self.viewModel.messages.isEmpty,
self.viewModel.pendingRunCount == 0,
!self.viewModel.hasBlockingRunActivity,
self.viewModel.pendingToolCalls.isEmpty,
self.viewModel.streamingAssistantText == nil
{

View file

@ -1,4 +1,9 @@
import Foundation
import OSLog
private let chatSessionActionsLogger = Logger(
subsystem: "ai.openclaw",
category: "OpenClawChat")
extension OpenClawChatViewModel {
public func refreshSessions(limit: Int? = nil) {
@ -35,4 +40,134 @@ extension OpenClawChatViewModel {
await self.fetchSessions(limit: nil, sessionSnapshot: self.currentSessionSnapshot())
}
}
/// One-shot session list fetch for search and archived browsing. Falls back
/// to locally filtering the cached active list when the gateway is
/// unreachable; archived rows exist only server-side, so archived mode
/// returns empty offline.
public func fetchSessionList(search: String?, archived: Bool) async -> [OpenClawChatSessionEntry] {
let normalizedSearch = search?.trimmingCharacters(in: .whitespacesAndNewlines)
let query = normalizedSearch?.isEmpty == false ? normalizedSearch : nil
do {
let res = try await self.transport.listSessions(
limit: Self.sessionListFetchLimit,
search: query,
archived: archived)
return OpenClawChatSessionListOrganizer.organize(res.sessions)
} catch {
// A superseded (cancelled) fetch must not produce fallback rows;
// the newer task owns the scoped list. Callers also guard on
// Task.isCancelled before applying results.
guard !(error is CancellationError), !Task.isCancelled else { return [] }
guard !archived else { return [] }
guard let query else { return self.sessions }
return OpenClawChatSessionListOrganizer.filter(self.sessions, search: query)
}
}
public func renameSession(key: String, label: String) {
let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let previous = self.sessions
if let index = self.sessions.firstIndex(where: { $0.key == key }) {
self.sessions[index].label = trimmed
self.sessions[index].displayName = trimmed
}
Task {
do {
try await self.transport.patchSession(
key: key,
label: trimmed,
category: nil,
pinned: nil,
archived: nil,
unread: nil)
self.refreshSessions()
} catch {
self.sessions = previous
self.errorText = error.localizedDescription
chatSessionActionsLogger.error(
"sessions.patch(label) failed \(error.localizedDescription, privacy: .public)")
}
}
}
public func setSessionPinned(key: String, pinned: Bool) {
let previous = self.sessions
if let index = self.sessions.firstIndex(where: { $0.key == key }) {
self.sessions[index].pinned = pinned
self.sessions[index].pinnedAt = pinned ? Date().timeIntervalSince1970 * 1000 : nil
self.sessions = OpenClawChatSessionListOrganizer.organize(self.sessions)
}
Task {
do {
try await self.transport.patchSession(
key: key,
label: nil,
category: nil,
pinned: pinned,
archived: nil,
unread: nil)
self.refreshSessions()
} catch {
self.sessions = previous
self.errorText = error.localizedDescription
chatSessionActionsLogger.error(
"sessions.patch(pinned) failed \(error.localizedDescription, privacy: .public)")
}
}
}
public func setSessionArchived(key: String, archived: Bool) {
guard archived else {
Task { await self.restoreSession(key: key) }
return
}
let previous = self.sessions
self.sessions.removeAll { $0.key == key }
Task {
do {
try await self.transport.patchSession(
key: key,
label: nil,
category: nil,
pinned: nil,
archived: true,
unread: nil)
if key == self.sessionKey {
// The archived session rejects new sends; move the user back
// to the main session instead of leaving a dead composer.
self.switchSession(to: self.resolvedMainSessionKey)
}
self.refreshSessions()
} catch {
self.sessions = previous
self.errorText = error.localizedDescription
chatSessionActionsLogger.error(
"sessions.patch(archived) failed \(error.localizedDescription, privacy: .public)")
}
}
}
/// Restores an archived session. Returns false (with `errorText` set) on
/// failure so open-flows can avoid switching into a still-archived session.
@discardableResult
public func restoreSession(key: String) async -> Bool {
do {
try await self.transport.patchSession(
key: key,
label: nil,
category: nil,
pinned: nil,
archived: false,
unread: nil)
self.refreshSessions()
return true
} catch {
self.errorText = error.localizedDescription
chatSessionActionsLogger.error(
"sessions.patch(archived=false) failed \(error.localizedDescription, privacy: .public)")
return false
}
}
}

View file

@ -62,6 +62,7 @@ public final class OpenClawChatViewModel {
}
public private(set) var pendingRunCount: Int = 0
private(set) var hasActiveSessionRunWithoutChatSnapshot = false
public private(set) var sessionKey: String {
didSet { self.syncContextUsageFraction() }
@ -178,6 +179,8 @@ public final class OpenClawChatViewModel {
private nonisolated(unsafe) var pendingRunTimeoutTasks: [String: Task<Void, Never>] = [:]
private var nextPendingRunTimeoutArmID: UInt64 = 0
private var pendingRunTimeoutArmIDs: [String: UInt64] = [:]
@ObservationIgnored
private nonisolated(unsafe) var activeSessionRunIndicatorTimeoutTask: Task<Void, Never>?
private let pendingRunTimeoutMs: UInt64 = 120_000
private static let postSendRefreshDelaysMs: [UInt64] = [
1500,
@ -299,7 +302,9 @@ public final class OpenClawChatViewModel {
self.eventTask = Task { [weak self, transport] in
let stream = transport.events()
for await evt in stream {
if Task.isCancelled { return }
if Task.isCancelled {
return
}
await MainActor.run { [weak self] in
self?.handleTransportEvent(evt)
}
@ -321,6 +326,7 @@ public final class OpenClawChatViewModel {
self.bootstrapTask?.cancel()
self.outboxRetryTask?.cancel()
self.outboxChangesTask?.cancel()
self.activeSessionRunIndicatorTimeoutTask?.cancel()
for (_, task) in self.pendingRunTimeoutTasks {
task.cancel()
}
@ -454,7 +460,9 @@ public final class OpenClawChatViewModel {
(agentChanged && self.usesMutableAgentRouting) ||
contractRoutingChanged
guard bootstrapIdentityChanged else {
if contractChanged, self.healthOK { flushOutboxIfNeeded() }
if contractChanged, self.healthOK {
flushOutboxIfNeeded()
}
return
}
// Restart when this key depends on a changed routing value so cleared
@ -513,7 +521,9 @@ public final class OpenClawChatViewModel {
}
private func usesMutableContractRouting(for contract: String?) -> Bool {
if self.usesMutableAgentRouting { return true }
if self.usesMutableAgentRouting {
return true
}
let parts = self.sessionKey
.trimmingCharacters(in: .whitespacesAndNewlines)
.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false)
@ -572,7 +582,7 @@ public final class OpenClawChatViewModel {
}
public var canSend: Bool {
!self.isSubmittingDraft && !self.isSending && self.pendingRunCount == 0 && self.hasDraftToSend
!self.isSubmittingDraft && !self.isSending && !self.hasBlockingRunActivity && self.hasDraftToSend
}
public var hasDraftToSend: Bool {
@ -596,6 +606,10 @@ public final class OpenClawChatViewModel {
!self.attachments.isEmpty
}
var hasBlockingRunActivity: Bool {
self.pendingRunCount > 0 || self.hasActiveSessionRunWithoutChatSnapshot
}
/// Applies external owner changes once recording or staging releases them.
public func attachmentOwnerActivityChanged() {
self.applyDeferredExternalStateIfReady()
@ -638,6 +652,40 @@ public final class OpenClawChatViewModel {
self.markTimelineChanged()
}
private 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.pendingRunTimeoutMs
self.activeSessionRunIndicatorTimeoutTask = Task { [weak self] in
do {
try await Task.sleep(nanoseconds: timeoutMs * 1_000_000)
} catch {
return
}
await MainActor.run {
self?.updateActiveSessionRunWithoutChatSnapshot(false)
}
}
}
private func clearActiveSessionRunIndicatorIfLatestUserAnswered() {
guard self.hasActiveSessionRunWithoutChatSnapshot,
!Self.hasUnansweredLatestUser(in: self.messages)
else { return }
self.updateActiveSessionRunWithoutChatSnapshot(false)
}
private func logDiagnostic(_ message: String) {
self.diagnosticsLog?(message)
}
@ -757,6 +805,7 @@ public final class OpenClawChatViewModel {
if canInvalidateOlderHistory {
self.markHistoryRequestApplied(request)
}
self.clearActiveSessionRunIndicatorIfLatestUserAnswered()
let appliedThinkingLevel = !self.prefersExplicitThinkingLevel
? Self.normalizedThinkingLevel(payload.thinkingLevel)
: nil
@ -834,6 +883,7 @@ public final class OpenClawChatViewModel {
self.isApplyingRunSnapshot = true
defer { self.isApplyingRunSnapshot = false }
self.updateActiveSessionRunWithoutChatSnapshot(false)
self.adoptRun(runId: runId, bufferedText: snapshot.text)
}
@ -870,6 +920,7 @@ public final class OpenClawChatViewModel {
self.clearPendingRuns(reason: nil)
self.pendingToolCallsById = [:]
self.updateStreamingAssistantText(nil)
self.updateActiveSessionRunWithoutChatSnapshot(false)
self.sessionId = nil
let historyRequest = self.beginHistoryRequest(captureLatestUserTurn: requestedSessionKey == nil)
let context = BootstrapContext(
@ -951,11 +1002,23 @@ public final class OpenClawChatViewModel {
refresh.supportsInFlightRunState,
!refresh.hasInFlightRun
{
self.clearPendingRuns(
reason: nil,
hapticEvent: self.assistantHapticEventAfterLatestUser())
self.pendingToolCallsById = [:]
self.updateStreamingAssistantText(nil)
if refresh.sessionHasActiveRun,
Self.hasUnansweredLatestUser(in: self.messages)
{
self.pendingToolCallsById = [:]
self.updateStreamingAssistantText(nil)
// Keep a known run ID authoritative so its stream and terminal
// events still route here. Synthesize activity only after the
// client has no run identity to preserve.
self.updateActiveSessionRunWithoutChatSnapshot(self.pendingRuns.isEmpty)
} else {
self.updateActiveSessionRunWithoutChatSnapshot(false)
self.clearPendingRuns(
reason: nil,
hapticEvent: self.assistantHapticEventAfterLatestUser())
self.pendingToolCallsById = [:]
self.updateStreamingAssistantText(nil)
}
}
await pollHealthIfNeeded(force: true, sessionSnapshot: context.session)
}
@ -1096,7 +1159,9 @@ public final class OpenClawChatViewModel {
return (rank, index, command)
}
.sorted {
if $0.0 != $1.0 { return $0.0 < $1.0 }
if $0.0 != $1.0 {
return $0.0 < $1.0
}
return $0.1 < $1.1
}
.map(\.2)
@ -1163,17 +1228,23 @@ public final class OpenClawChatViewModel {
private func handleLocalSlashCommandIfNeeded(_ command: String, draftInput: String) async -> Bool {
if command == "/new" {
if self.input == draftInput { self.input = "" }
if self.input == draftInput {
self.input = ""
}
await self.performStartNewSession(worktree: false)
return true
}
if Self.resetTriggers.contains(command) {
if self.input == draftInput { self.input = "" }
if self.input == draftInput {
self.input = ""
}
await self.performReset()
return true
}
if Self.compactTriggers.contains(command) {
if self.input == draftInput { self.input = "" }
if self.input == draftInput {
self.input = ""
}
await self.performCompact()
return true
}
@ -1202,10 +1273,11 @@ public final class OpenClawChatViewModel {
self.logDiagnostic("chat.ui send ignored reason=sending sessionKey=\(self.sessionKey)")
return
}
guard self.pendingRuns.isEmpty else {
guard !self.hasBlockingRunActivity else {
self.logDiagnostic(
"chat.ui send ignored reason=pending sessionKey=\(self.sessionKey) "
+ "pending=\(self.pendingRunCount)")
+ "pending=\(self.pendingRunCount) "
+ "activeWithoutSnapshot=\(self.hasActiveSessionRunWithoutChatSnapshot)")
return
}
let draftInput = self.input
@ -1380,7 +1452,9 @@ public final class OpenClawChatViewModel {
self.runMessageScopesByRunID[runId] = currentRunMessageScope()
// Clear input immediately for responsive UX (before network await)
if self.input == draftInput { self.input = "" }
if self.input == draftInput {
self.input = ""
}
let sentAttachmentIDs = Set(draftAttachments.map(\.id))
self.attachments.removeAll { sentAttachmentIDs.contains($0.id) }
@ -1533,7 +1607,9 @@ public final class OpenClawChatViewModel {
func fetchSessions(limit: Int?, sessionSnapshot: SessionSnapshot? = nil) async {
do {
let res = try await transport.listSessions(limit: limit, search: nil, archived: false)
if let sessionSnapshot, !self.isCurrentSession(sessionSnapshot) { return }
if let sessionSnapshot, !self.isCurrentSession(sessionSnapshot) {
return
}
let organized = OpenClawChatSessionListOrganizer.organize(res.sessions)
self.sessions = organized
self.sessionDefaults = res.defaults
@ -1546,140 +1622,12 @@ public final class OpenClawChatViewModel {
}
}
/// One-shot session list fetch for search and archived browsing. Falls back
/// to locally filtering the cached active list when the gateway is
/// unreachable; archived rows exist only server-side, so archived mode
/// returns empty offline.
public func fetchSessionList(search: String?, archived: Bool) async -> [OpenClawChatSessionEntry] {
let normalizedSearch = search?.trimmingCharacters(in: .whitespacesAndNewlines)
let query = normalizedSearch?.isEmpty == false ? normalizedSearch : nil
do {
let res = try await self.transport.listSessions(
limit: Self.sessionListFetchLimit,
search: query,
archived: archived)
return OpenClawChatSessionListOrganizer.organize(res.sessions)
} catch {
// A superseded (cancelled) fetch must not produce fallback rows;
// the newer task owns the scoped list. Callers also guard on
// Task.isCancelled before applying results.
guard !(error is CancellationError), !Task.isCancelled else { return [] }
guard !archived else { return [] }
guard let query else { return self.sessions }
return OpenClawChatSessionListOrganizer.filter(self.sessions, search: query)
}
}
public func renameSession(key: String, label: String) {
let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let previous = self.sessions
if let index = self.sessions.firstIndex(where: { $0.key == key }) {
self.sessions[index].label = trimmed
self.sessions[index].displayName = trimmed
}
Task {
do {
try await self.transport.patchSession(
key: key,
label: trimmed,
category: nil,
pinned: nil,
archived: nil,
unread: nil)
self.refreshSessions()
} catch {
self.sessions = previous
self.errorText = error.localizedDescription
chatUILogger.error(
"sessions.patch(label) failed \(error.localizedDescription, privacy: .public)")
}
}
}
public func setSessionPinned(key: String, pinned: Bool) {
let previous = self.sessions
if let index = self.sessions.firstIndex(where: { $0.key == key }) {
self.sessions[index].pinned = pinned
self.sessions[index].pinnedAt = pinned ? Date().timeIntervalSince1970 * 1000 : nil
self.sessions = OpenClawChatSessionListOrganizer.organize(self.sessions)
}
Task {
do {
try await self.transport.patchSession(
key: key,
label: nil,
category: nil,
pinned: pinned,
archived: nil,
unread: nil)
self.refreshSessions()
} catch {
self.sessions = previous
self.errorText = error.localizedDescription
chatUILogger.error(
"sessions.patch(pinned) failed \(error.localizedDescription, privacy: .public)")
}
}
}
public func setSessionArchived(key: String, archived: Bool) {
guard archived else {
Task { await self.restoreSession(key: key) }
return
}
let previous = self.sessions
self.sessions.removeAll { $0.key == key }
Task {
do {
try await self.transport.patchSession(
key: key,
label: nil,
category: nil,
pinned: nil,
archived: true,
unread: nil)
if key == self.sessionKey {
// The archived session rejects new sends; move the user back
// to the main session instead of leaving a dead composer.
self.switchSession(to: self.resolvedMainSessionKey)
}
self.refreshSessions()
} catch {
self.sessions = previous
self.errorText = error.localizedDescription
chatUILogger.error(
"sessions.patch(archived) failed \(error.localizedDescription, privacy: .public)")
}
}
}
/// Restores an archived session. Returns false (with `errorText` set) on
/// failure so open-flows can avoid switching into a still-archived session.
@discardableResult
public func restoreSession(key: String) async -> Bool {
do {
try await self.transport.patchSession(
key: key,
label: nil,
category: nil,
pinned: nil,
archived: false,
unread: nil)
self.refreshSessions()
return true
} catch {
self.errorText = error.localizedDescription
chatUILogger.error(
"sessions.patch(archived=false) failed \(error.localizedDescription, privacy: .public)")
return false
}
}
private func fetchModels(sessionSnapshot: SessionSnapshot? = nil) async {
do {
let modelChoices = try await transport.listModels()
if let sessionSnapshot, !self.isCurrentSession(sessionSnapshot) { return }
if let sessionSnapshot, !self.isCurrentSession(sessionSnapshot) {
return
}
self.modelChoices = modelChoices
self.syncSelectedModel()
syncThinkingLevelOptions()
@ -1783,6 +1731,7 @@ public final class OpenClawChatViewModel {
self.sessionId = nil
self.pendingToolCallsById = [:]
self.updateStreamingAssistantText(nil)
self.updateActiveSessionRunWithoutChatSnapshot(false)
self.resetSlashCommandCatalog()
self.clearPendingRuns(reason: nil)
}
@ -1813,7 +1762,7 @@ public final class OpenClawChatViewModel {
func performCompact() async {
guard !self.isCompacting else { return }
guard !self.isSending, self.pendingRuns.isEmpty, !self.isAborting else {
guard !self.isSending, !self.hasBlockingRunActivity, !self.isAborting else {
self.errorText = "Wait for the current response before compacting the session."
return
}
@ -2185,10 +2134,12 @@ public final class OpenClawChatViewModel {
// run's final event already cleared pending state. Same-content turns
// without this key remain distinct.
if adoptCorrelatedUserMessage(incoming: sanitized) {
self.clearActiveSessionRunIndicatorIfLatestUserAnswered()
self.applyDeferredExternalStateIfReady()
return
}
if adoptProvisionalFinalMessage(incoming: sanitized) {
self.clearActiveSessionRunIndicatorIfLatestUserAnswered()
return
}
@ -2196,6 +2147,7 @@ public final class OpenClawChatViewModel {
replaceMessages(Self.dedupeMessages(reconciled))
pruneProvisionalFinalMessages()
pruneRunMessageScopes()
self.clearActiveSessionRunIndicatorIfLatestUserAnswered()
self.applyDeferredExternalStateIfReady()
}
@ -2234,6 +2186,7 @@ public final class OpenClawChatViewModel {
}
if chat.state == "final" || chat.state == "aborted" || chat.state == "error" {
self.invalidateHistorySnapshots()
self.updateActiveSessionRunWithoutChatSnapshot(false)
}
self.invalidateRunSnapshots()
if !isOurRun {
@ -2368,6 +2321,7 @@ public final class OpenClawChatViewModel {
switch evt.stream {
case "assistant":
if let text = evt.data["text"]?.value as? String {
self.updateActiveSessionRunWithoutChatSnapshot(false)
self.updateStreamingAssistantText(text)
}
case "lifecycle":
@ -2377,6 +2331,7 @@ public final class OpenClawChatViewModel {
guard let name = evt.data["name"]?.value as? String else { return }
guard let toolCallId = evt.data["toolCallId"]?.value as? String else { return }
if phase == "start" {
self.updateActiveSessionRunWithoutChatSnapshot(false)
let args = evt.data["args"]
self.pendingToolCallsById[toolCallId] = OpenClawChatPendingToolCall(
toolCallId: toolCallId,
@ -2407,6 +2362,7 @@ public final class OpenClawChatViewModel {
guard isTerminalPhase || isFailure || aborted || isSuccessfulStatus else { return }
self.invalidateHistorySnapshots()
self.updateActiveSessionRunWithoutChatSnapshot(false)
if isFailure || aborted {
self.errorText = Self.agentLifecycleErrorMessage(evt, aborted: aborted)
@ -2557,6 +2513,15 @@ public final class OpenClawChatViewModel {
if refresh.hasInFlightRun {
return true
}
if refresh.sessionHasActiveRun,
Self.hasUnansweredLatestUser(in: self.messages)
{
// A session-level active bit cannot identify a new chat run,
// but it is enough to retain the run ID this client already owns.
self.pendingToolCallsById = [:]
self.updateStreamingAssistantText(nil)
return true
}
self.clearPendingRun(runId)
self.pendingToolCallsById = [:]
self.updateStreamingAssistantText(nil)
@ -2755,7 +2720,12 @@ public final class OpenClawChatViewModel {
@discardableResult
private func refreshHistoryAfterRun(historyRequest request: HistoryRequest? = nil) async
-> (applied: Bool, runSnapshotApplied: Bool, supportsInFlightRunState: Bool, hasInFlightRun: Bool)
-> (
applied: Bool,
runSnapshotApplied: Bool,
supportsInFlightRunState: Bool,
hasInFlightRun: Bool,
sessionHasActiveRun: Bool)
{
let request = request ?? self.beginHistoryRequest()
do {
@ -2767,6 +2737,7 @@ public final class OpenClawChatViewModel {
for: request,
preservingOptimisticLocalMessages: true)
let hasInFlightRun = Self.normalizedRunID(payload.inFlightRun?.runId) != nil
let sessionHasActiveRun = payload.sessionInfo?.hasActiveRun == true
// `hasActiveRun` is session-wide and can be true for an embedded agent run.
// Its presence capability-gates an authoritative missing chat snapshot, but
// only `inFlightRun` establishes ownership of the pending chat run.
@ -2775,10 +2746,11 @@ public final class OpenClawChatViewModel {
applied,
applied && runSnapshotApplied,
supportsInFlightRunState,
hasInFlightRun)
hasInFlightRun,
sessionHasActiveRun)
} catch {
chatUILogger.error("refresh history failed \(error.localizedDescription, privacy: .public)")
return (false, false, false, false)
return (false, false, false, false, false)
}
}

View file

@ -222,7 +222,7 @@ public struct OpenClawChatWindowShell: View {
} label: {
Label("Compact Session", systemImage: "arrow.down.right.and.arrow.up.left")
}
.disabled(self.viewModel.pendingRunCount > 0)
.disabled(self.viewModel.hasBlockingRunActivity)
Button(role: .destructive) {
self.isConfirmingClearHistory = true

View file

@ -1205,12 +1205,112 @@ struct ChatViewModelTests {
#expect(await MainActor.run { !vm.canSend })
}
@Test func `active session history preserves the known pending run`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let historyCalls = AsyncCounter()
let userOnlyHistory = historyPayload(
messages: [chatTextMessage(role: "user", text: "quiet task", timestamp: now)],
hasActiveRun: true)
let (transport, vm) = await makeViewModel(
historyResponses: [historyPayload(), userOnlyHistory, userOnlyHistory, userOnlyHistory],
requestHistoryHook: { _ in _ = await historyCalls.increment() },
sendMessageStatus: "pending")
try await loadAndWaitBootstrap(vm: vm)
await sendUserMessage(vm, text: "quiet task")
try await waitUntil("send refresh applies user-only history") {
await historyCalls.current() == 2
}
#expect(await MainActor.run { vm.pendingRunCount == 1 })
try await waitUntil("post-send fallback keeps known run ownership", timeoutSeconds: 7.0) {
let historyCount = await historyCalls.current()
let pendingRunCount = await MainActor.run { vm.pendingRunCount }
return historyCount >= 3 && pendingRunCount == 1
}
await MainActor.run { vm.resumeFromForeground() }
try await waitUntil("foreground history applies") {
await historyCalls.current() >= 4
}
#expect(await MainActor.run { vm.pendingRunCount == 1 })
#expect(await MainActor.run { !vm.hasActiveSessionRunWithoutChatSnapshot })
await MainActor.run { vm.input = "another task" }
#expect(await MainActor.run { !vm.canSend })
await MainActor.run { vm.send() }
await Task.yield()
#expect(await transport.sentMessages() == ["quiet task"])
let runId = try await waitForLastSentRunId(transport)
emitAgentLifecycleEnd(transport: transport, runId: runId)
try await waitUntil("terminal lifecycle clears known run activity") {
await MainActor.run {
vm.pendingRunCount == 0 && !vm.hasActiveSessionRunWithoutChatSnapshot
}
}
}
@Test func `foreground synthesizes activity when no run snapshot or local run exists`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let historyCalls = AsyncCounter()
let userOnlyHistory = historyPayload(
messages: [chatTextMessage(role: "user", text: "quiet task", timestamp: now)],
hasActiveRun: true)
let (transport, vm) = await makeViewModel(
historyResponses: [userOnlyHistory, userOnlyHistory],
requestHistoryHook: { _ in _ = await historyCalls.increment() })
try await loadAndWaitBootstrap(vm: vm)
#expect(await MainActor.run { vm.pendingRunCount == 0 })
await MainActor.run { vm.resumeFromForeground() }
try await waitUntil("foreground history applies") {
await historyCalls.current() == 2
}
#expect(await MainActor.run { vm.hasActiveSessionRunWithoutChatSnapshot })
transport.emit(
.sessionMessage(
OpenClawSessionMessageEventPayload(
sessionKey: "main",
message: chatTextModelMessage(role: "assistant", text: "done", timestamp: now + 1),
messageId: "msg-done",
messageSeq: 2)))
try await waitUntil("assistant session message clears activity indicator") {
await MainActor.run { !vm.hasActiveSessionRunWithoutChatSnapshot }
}
}
@Test func `session switch clears active session activity indicator`() async throws {
let now = Date().timeIntervalSince1970 * 1000
let historyCalls = AsyncCounter()
let userOnlyHistory = historyPayload(
messages: [chatTextMessage(role: "user", text: "quiet task", timestamp: now)],
hasActiveRun: true)
let (_, vm) = await makeViewModel(
historyResponses: [userOnlyHistory, userOnlyHistory, historyPayload(sessionKey: "other")],
requestHistoryHook: { _ in _ = await historyCalls.increment() })
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run { vm.resumeFromForeground() }
try await waitUntil("foreground history applies") {
await historyCalls.current() == 2
}
#expect(await MainActor.run { vm.hasActiveSessionRunWithoutChatSnapshot })
await MainActor.run { vm.switchSession(to: "other") }
try await waitUntil("other session bootstrap applies") {
await historyCalls.current() == 3
}
await MainActor.run { vm.input = "new task" }
#expect(await MainActor.run { !vm.hasActiveSessionRunWithoutChatSnapshot })
#expect(await MainActor.run { vm.canSend })
}
@Test func `foreground clears completed run without assistant output`() async throws {
let activeHistory = historyPayload(
messages: [chatTextMessage(role: "user", text: "quiet task", timestamp: 1)],
inFlightRun: OpenClawChatInFlightRun(runId: "run-quiet", text: ""))
let completedHistory = historyPayload(
messages: [chatTextMessage(role: "user", text: "quiet task", timestamp: 1)])
messages: [chatTextMessage(role: "user", text: "quiet task", timestamp: 1)],
hasActiveRun: false)
let (_, vm) = await makeViewModel(historyResponses: [activeHistory, completedHistory])
try await loadAndWaitBootstrap(vm: vm)
@ -1219,6 +1319,26 @@ struct ChatViewModelTests {
try await waitUntil("silent completed run clears") {
await MainActor.run { vm.pendingRunCount == 0 }
}
#expect(await MainActor.run { !vm.hasActiveSessionRunWithoutChatSnapshot })
}
@Test func `foreground active session with answered chat does not show activity indicator`() async throws {
let answeredHistory = historyPayload(
messages: [
chatTextMessage(role: "user", text: "done", timestamp: 1),
chatTextMessage(role: "assistant", text: "finished", timestamp: 2),
],
hasActiveRun: true)
let (_, vm) = await makeViewModel(historyResponses: [historyPayload(), answeredHistory])
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run { vm.resumeFromForeground() }
try await waitUntil("answered history applies") {
await MainActor.run { vm.messages.count == 2 }
}
#expect(await MainActor.run { vm.pendingRunCount == 0 })
#expect(await MainActor.run { !vm.hasActiveSessionRunWithoutChatSnapshot })
}
@Test func `foreground missing snapshot does not clear an in-flight send`() async throws {