mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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:
parent
a5f5282816
commit
ccdb60e5db
7 changed files with 390 additions and 163 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue