Skip to content

Commit ccdb60e

Browse files
jmcteYour Namesteipete
authored
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 <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent a5f5282 commit ccdb60e

7 files changed

Lines changed: 390 additions & 163 deletions

File tree

apps/.i18n/native-source.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21875,31 +21875,31 @@
2187521875
},
2187621876
{
2187721877
"kind": "conditional-branch",
21878-
"line": 1481,
21878+
"line": 1555,
2187921879
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift",
2188021880
"source": "delivery unconfirmed",
2188121881
"surface": "apple",
2188221882
"id": "native.apple.2b45abdc56b2caed"
2188321883
},
2188421884
{
2188521885
"kind": "conditional-branch",
21886-
"line": 1481,
21886+
"line": 1555,
2188721887
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift",
2188821888
"source": "queued after route change",
2188921889
"surface": "apple",
2189021890
"id": "native.apple.722f1f90b97e8e45"
2189121891
},
2189221892
{
2189321893
"kind": "ui-localized-call",
21894-
"line": 1705,
21894+
"line": 1653,
2189521895
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift",
2189621896
"source": "Remove attachments or wait for delivery to resolve before switching chats.",
2189721897
"surface": "apple",
2189821898
"id": "native.apple.710c7bd117a7cc12"
2189921899
},
2190021900
{
2190121901
"kind": "ui-localized-call",
21902-
"line": 1761,
21902+
"line": 1709,
2190321903
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift",
2190421904
"source": "Remove attachments or wait for delivery to resolve before starting a new chat.",
2190521905
"surface": "apple",

apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,7 @@ struct OpenClawChatComposer: View {
573573
/// draft is empty, swapping to the send button once the user types.
574574
@ViewBuilder
575575
private var cleanTrailingControl: some View {
576-
if !self.viewModel.hasDraftToSend, self.viewModel.pendingRunCount == 0, let talkControl {
576+
if !self.viewModel.hasDraftToSend, !self.viewModel.hasBlockingRunActivity, let talkControl {
577577
self.compactTalkButton(talkControl)
578578
} else {
579579
self.sendButton

apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,7 @@ public struct OpenClawChatView: View {
370370
self.messageRow(for: msg)
371371
}
372372

373-
if self.viewModel.pendingRunCount > 0 {
373+
if self.viewModel.hasBlockingRunActivity {
374374
ChatTypingIndicatorBubble(
375375
style: self.style,
376376
assistantName: self.assistantName,
@@ -657,7 +657,7 @@ public struct OpenClawChatView: View {
657657
}
658658

659659
private var hasVisibleTransientContent: Bool {
660-
self.viewModel.pendingRunCount > 0 ||
660+
self.viewModel.hasBlockingRunActivity ||
661661
!self.viewModel.pendingToolCalls.isEmpty ||
662662
(self.viewModel.streamingAssistantText.map {
663663
AssistantTextParser.hasVisibleContent(in: $0, includeThinking: self.showsAssistantTrace)
@@ -716,7 +716,7 @@ public struct OpenClawChatView: View {
716716
!(self.viewModel.streamingAssistantText.map {
717717
AssistantTextParser.hasVisibleContent(in: $0, includeThinking: self.showsAssistantTrace)
718718
} ?? false) &&
719-
self.viewModel.pendingRunCount == 0 &&
719+
!self.viewModel.hasBlockingRunActivity &&
720720
self.viewModel.pendingToolCalls.isEmpty
721721
}
722722

@@ -768,7 +768,7 @@ public struct OpenClawChatView: View {
768768
private func handleTimelineChange() {
769769
guard self.hasPerformedInitialScroll else { return }
770770
if self.viewModel.messages.isEmpty,
771-
self.viewModel.pendingRunCount == 0,
771+
!self.viewModel.hasBlockingRunActivity,
772772
self.viewModel.pendingToolCalls.isEmpty,
773773
self.viewModel.streamingAssistantText == nil
774774
{

apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+SessionActions.swift

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
import Foundation
2+
import OSLog
3+
4+
private let chatSessionActionsLogger = Logger(
5+
subsystem: "ai.openclaw",
6+
category: "OpenClawChat")
27

38
extension OpenClawChatViewModel {
49
public func refreshSessions(limit: Int? = nil) {
@@ -35,4 +40,134 @@ extension OpenClawChatViewModel {
3540
await self.fetchSessions(limit: nil, sessionSnapshot: self.currentSessionSnapshot())
3641
}
3742
}
43+
44+
/// One-shot session list fetch for search and archived browsing. Falls back
45+
/// to locally filtering the cached active list when the gateway is
46+
/// unreachable; archived rows exist only server-side, so archived mode
47+
/// returns empty offline.
48+
public func fetchSessionList(search: String?, archived: Bool) async -> [OpenClawChatSessionEntry] {
49+
let normalizedSearch = search?.trimmingCharacters(in: .whitespacesAndNewlines)
50+
let query = normalizedSearch?.isEmpty == false ? normalizedSearch : nil
51+
do {
52+
let res = try await self.transport.listSessions(
53+
limit: Self.sessionListFetchLimit,
54+
search: query,
55+
archived: archived)
56+
return OpenClawChatSessionListOrganizer.organize(res.sessions)
57+
} catch {
58+
// A superseded (cancelled) fetch must not produce fallback rows;
59+
// the newer task owns the scoped list. Callers also guard on
60+
// Task.isCancelled before applying results.
61+
guard !(error is CancellationError), !Task.isCancelled else { return [] }
62+
guard !archived else { return [] }
63+
guard let query else { return self.sessions }
64+
return OpenClawChatSessionListOrganizer.filter(self.sessions, search: query)
65+
}
66+
}
67+
68+
public func renameSession(key: String, label: String) {
69+
let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines)
70+
guard !trimmed.isEmpty else { return }
71+
let previous = self.sessions
72+
if let index = self.sessions.firstIndex(where: { $0.key == key }) {
73+
self.sessions[index].label = trimmed
74+
self.sessions[index].displayName = trimmed
75+
}
76+
Task {
77+
do {
78+
try await self.transport.patchSession(
79+
key: key,
80+
label: trimmed,
81+
category: nil,
82+
pinned: nil,
83+
archived: nil,
84+
unread: nil)
85+
self.refreshSessions()
86+
} catch {
87+
self.sessions = previous
88+
self.errorText = error.localizedDescription
89+
chatSessionActionsLogger.error(
90+
"sessions.patch(label) failed \(error.localizedDescription, privacy: .public)")
91+
}
92+
}
93+
}
94+
95+
public func setSessionPinned(key: String, pinned: Bool) {
96+
let previous = self.sessions
97+
if let index = self.sessions.firstIndex(where: { $0.key == key }) {
98+
self.sessions[index].pinned = pinned
99+
self.sessions[index].pinnedAt = pinned ? Date().timeIntervalSince1970 * 1000 : nil
100+
self.sessions = OpenClawChatSessionListOrganizer.organize(self.sessions)
101+
}
102+
Task {
103+
do {
104+
try await self.transport.patchSession(
105+
key: key,
106+
label: nil,
107+
category: nil,
108+
pinned: pinned,
109+
archived: nil,
110+
unread: nil)
111+
self.refreshSessions()
112+
} catch {
113+
self.sessions = previous
114+
self.errorText = error.localizedDescription
115+
chatSessionActionsLogger.error(
116+
"sessions.patch(pinned) failed \(error.localizedDescription, privacy: .public)")
117+
}
118+
}
119+
}
120+
121+
public func setSessionArchived(key: String, archived: Bool) {
122+
guard archived else {
123+
Task { await self.restoreSession(key: key) }
124+
return
125+
}
126+
let previous = self.sessions
127+
self.sessions.removeAll { $0.key == key }
128+
Task {
129+
do {
130+
try await self.transport.patchSession(
131+
key: key,
132+
label: nil,
133+
category: nil,
134+
pinned: nil,
135+
archived: true,
136+
unread: nil)
137+
if key == self.sessionKey {
138+
// The archived session rejects new sends; move the user back
139+
// to the main session instead of leaving a dead composer.
140+
self.switchSession(to: self.resolvedMainSessionKey)
141+
}
142+
self.refreshSessions()
143+
} catch {
144+
self.sessions = previous
145+
self.errorText = error.localizedDescription
146+
chatSessionActionsLogger.error(
147+
"sessions.patch(archived) failed \(error.localizedDescription, privacy: .public)")
148+
}
149+
}
150+
}
151+
152+
/// Restores an archived session. Returns false (with `errorText` set) on
153+
/// failure so open-flows can avoid switching into a still-archived session.
154+
@discardableResult
155+
public func restoreSession(key: String) async -> Bool {
156+
do {
157+
try await self.transport.patchSession(
158+
key: key,
159+
label: nil,
160+
category: nil,
161+
pinned: nil,
162+
archived: false,
163+
unread: nil)
164+
self.refreshSessions()
165+
return true
166+
} catch {
167+
self.errorText = error.localizedDescription
168+
chatSessionActionsLogger.error(
169+
"sessions.patch(archived=false) failed \(error.localizedDescription, privacy: .public)")
170+
return false
171+
}
172+
}
38173
}

0 commit comments

Comments
 (0)