Skip to content

Commit 06789ca

Browse files
authored
fix(ios): keep offline chat sends on their original route (#100942)
1 parent 68487e4 commit 06789ca

42 files changed

Lines changed: 5832 additions & 947 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/.i18n/native-source.json

Lines changed: 126 additions & 70 deletions
Large diffs are not rendered by default.

apps/ios/Sources/Chat/IOSGatewayChatTransport.swift

Lines changed: 197 additions & 33 deletions
Large diffs are not rendered by default.

apps/ios/Sources/Design/ChatProTab.swift

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ struct ChatProTab: View {
1010

1111
@Environment(NodeAppModel.self) private var appModel
1212
@State private var viewModel: OpenClawChatViewModel?
13-
@State private var viewModelTransportModeID = ""
13+
@State private var viewModelOwnerID = ""
1414
@State private var transcriptShareItem: TranscriptShareItem?
1515
@State private var showsTranscriptExportError = false
1616
// Transport can start unscoped while the UI uses its "main" fallback.
17-
// Track the real agent separately so gateway metadata always rebuilds it.
17+
// Track the real agent so gateway metadata replaces the captured transport.
1818
@State private var viewModelTransportAgentID = ""
19-
@State private var viewModelAgentID = ""
19+
@State private var viewModelRoutingContract = ""
2020
let headerLeadingAction: OpenClawSidebarHeaderAction?
2121
let headerTitle: String?
2222
let showsAgentBadge: Bool
@@ -50,12 +50,13 @@ struct ChatProTab: View {
5050
}
5151
}
5252
.task {
53+
await self.appModel.restoreChatSessionRoutingIdentityIfNeeded()
5354
self.syncChatViewModel()
5455
}
5556
.onChange(of: self.appModel.chatSessionKey) { _, _ in
5657
self.syncChatViewModel()
5758
}
58-
.onChange(of: self.appModel.chatViewModelIdentityID) { _, _ in
59+
.onChange(of: self.appModel.chatViewModelOwnerID) { _, _ in
5960
self.syncChatViewModel()
6061
}
6162
.onChange(of: self.appModel.chatAgentId) { _, _ in
@@ -64,6 +65,9 @@ struct ChatProTab: View {
6465
.onChange(of: self.appModel.gatewayDefaultAgentId) { _, _ in
6566
self.syncChatViewModel()
6667
}
68+
.onChange(of: self.appModel.chatSessionRoutingContract) { _, _ in
69+
self.syncChatViewModel()
70+
}
6771
.onChange(of: self.appModel.isAppleReviewDemoModeEnabled) { _, _ in
6872
self.syncChatViewModel()
6973
self.viewModel?.refresh()
@@ -133,7 +137,8 @@ struct ChatProTab: View {
133137
assistantAvatarTint: OpenClawBrand.accent,
134138
showsAssistantAvatars: false,
135139
composerChrome: .clean,
136-
isComposerEnabled: self.gatewayConnected,
140+
isComposerEnabled: self.gatewayConnected || self.canQueueOffline,
141+
isAttachmentInputEnabled: self.gatewayConnected,
137142
messagePlaceholder: self.messagePlaceholder,
138143
emptyAssistantIntro: String(localized: "What would you like to work on?"),
139144
emptyAssistantPrompts: Self.emptyAssistantPrompts,
@@ -166,38 +171,48 @@ struct ChatProTab: View {
166171
let sessionKey = self.appModel.chatSessionKey
167172
// Includes the cache gateway identity so switching paired gateways
168173
// rebuilds the view model even while the transport mode stays the same.
169-
let transportModeID = self.appModel.chatViewModelIdentityID
170-
let transportAgentID = Self.transportAgentID(self.appModel.chatAgentId)
171-
let agentID = self.activeAgentID
174+
let ownerID = self.appModel.chatViewModelOwnerID
175+
let deliveryAgentID = self.appModel.chatDeliveryAgentId
176+
let transportAgentID = Self.transportAgentID(deliveryAgentID)
177+
let routingContract = self.appModel.chatSessionRoutingContract ?? ""
172178
guard let viewModel else {
173-
self.viewModelTransportModeID = transportModeID
179+
self.viewModelOwnerID = ownerID
174180
self.viewModelTransportAgentID = transportAgentID
175-
self.viewModelAgentID = agentID
176-
self.viewModel = self.makeChatViewModel(sessionKey: sessionKey, activeAgentId: agentID)
181+
self.viewModelRoutingContract = routingContract
182+
self.viewModel = self.makeChatViewModel(sessionKey: sessionKey)
177183
return
178184
}
179-
if self.viewModelTransportModeID != transportModeID ||
180-
self.viewModelTransportAgentID != transportAgentID ||
181-
self.viewModelAgentID != agentID
185+
if Self.requiresViewModelRebuild(
186+
currentOwnerID: self.viewModelOwnerID,
187+
nextOwnerID: ownerID,
188+
currentTransportAgentID: self.viewModelTransportAgentID,
189+
nextTransportAgentID: transportAgentID)
182190
{
183-
self.viewModelTransportModeID = transportModeID
191+
self.viewModelOwnerID = ownerID
184192
self.viewModelTransportAgentID = transportAgentID
185-
self.viewModelAgentID = agentID
186-
self.viewModel = self.makeChatViewModel(sessionKey: sessionKey, activeAgentId: agentID)
193+
self.viewModelRoutingContract = routingContract
194+
self.viewModel = self.makeChatViewModel(sessionKey: sessionKey)
187195
return
188196
}
197+
if self.viewModelRoutingContract != routingContract {
198+
self.viewModelRoutingContract = routingContract
199+
viewModel.syncSessionRoutingContract(self.appModel.chatSessionRoutingContract)
200+
}
189201
guard viewModel.sessionKey != sessionKey else { return }
190202
viewModel.syncSession(to: sessionKey)
191203
}
192204

193-
private func makeChatViewModel(sessionKey: String, activeAgentId: String? = nil) -> OpenClawChatViewModel {
205+
private func makeChatViewModel(sessionKey: String) -> OpenClawChatViewModel {
194206
// One store instance backs both seams so the transcript cache and the
195207
// offline outbox share a single SQLite connection.
196208
let offlineStore = self.appModel.makeChatOfflineStore()
197209
return OpenClawChatViewModel(
198210
sessionKey: sessionKey,
199-
transport: self.appModel.makeChatTransport(),
200-
activeAgentId: activeAgentId,
211+
// Bind durable rows and their transport lease to the exact same
212+
// gateway owner even if app state switches between these calls.
213+
transport: self.appModel.makeChatTransport(outboxGatewayID: offlineStore?.gatewayID),
214+
activeAgentId: self.appModel.chatDeliveryAgentId,
215+
sessionRoutingContract: self.appModel.chatSessionRoutingContract,
201216
transcriptCache: offlineStore,
202217
outbox: offlineStore,
203218
onSessionChanged: { sessionKey in
@@ -362,7 +377,18 @@ struct ChatProTab: View {
362377
}
363378

364379
private var messagePlaceholder: String {
365-
self.gatewayConnected ? "Message \(self.agentDisplayName)..." : "Connect to a gateway"
380+
if self.gatewayConnected {
381+
return String(localized: "Message \(self.agentDisplayName)...")
382+
}
383+
if self.canQueueOffline {
384+
return String(localized: "Message \(self.agentDisplayName); sends when connected")
385+
}
386+
return String(localized: "Connect to a gateway")
387+
}
388+
389+
private var canQueueOffline: Bool {
390+
self.viewModel?.supportsOfflineTextOutbox == true &&
391+
self.appModel.hasVerifiedChatOfflineRoutingIdentity
366392
}
367393

368394
private var headerDisplayTitle: String {
@@ -413,6 +439,15 @@ struct ChatProTab: View {
413439
value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
414440
}
415441

442+
nonisolated static func requiresViewModelRebuild(
443+
currentOwnerID: String,
444+
nextOwnerID: String,
445+
currentTransportAgentID: String,
446+
nextTransportAgentID: String) -> Bool
447+
{
448+
currentOwnerID != nextOwnerID || currentTransportAgentID != nextTransportAgentID
449+
}
450+
416451
nonisolated static let emptyAssistantPrompts: [OpenClawChatView.StarterPrompt] = [
417452
OpenClawChatView.StarterPrompt(
418453
id: "summarize-status",

apps/ios/Sources/Model/NodeAppModel.swift

Lines changed: 95 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ final class NodeAppModel {
210210
}
211211

212212
private var mainSessionBaseKey: String = "main"
213+
private var gatewaySessionScope: String?
213214
private var focusedChatSessionKey: String?
214215
// Two-part unread guard mirroring Android: the opened key survives read
215216
// confirmations so later unread episodes on the same open chat re-acknowledge;
@@ -329,7 +330,7 @@ final class NodeAppModel {
329330
return self.isOperatorGatewayConnected ? "operator" : "offline"
330331
}
331332

332-
func makeChatTransport() -> any OpenClawChatTransport {
333+
func makeChatTransport(outboxGatewayID: String? = nil) -> any OpenClawChatTransport {
333334
if self.isScreenshotFixtureModeEnabled {
334335
return LocalFixtureChatTransport(fixture: .appScreenshots)
335336
}
@@ -338,7 +339,8 @@ final class NodeAppModel {
338339
}
339340
return IOSGatewayChatTransport(
340341
gateway: self.operatorSession,
341-
globalAgentId: self.chatAgentId)
342+
globalAgentId: self.chatDeliveryAgentId,
343+
outboxGatewayID: outboxGatewayID)
342344
}
343345

344346
/// Gateway identity the transcript cache is scoped to: the active
@@ -349,6 +351,7 @@ final class NodeAppModel {
349351
var chatTranscriptCacheGatewayID: String? {
350352
guard !self.isLocalGatewayFixtureEnabled else { return nil }
351353
let stableID = self.activeGatewayConnectConfig?.effectiveStableID
354+
?? self.connectedGatewayID
352355
?? GatewaySettingsStore.loadLastGatewayConnection()?.stableID
353356
guard let stableID, !stableID.isEmpty else { return nil }
354357
return stableID
@@ -362,6 +365,14 @@ final class NodeAppModel {
362365
"\(self.chatTransportModeID)|\(self.chatTranscriptCacheGatewayID ?? "")|\(self.chatTranscriptCacheGeneration)"
363366
}
364367

368+
/// Stable owner key for the long-lived chat view model. Connectivity still
369+
/// changes `chatViewModelIdentityID` for session-list refreshes, but must
370+
/// not rebuild Chat and discard an offline draft on the same gateway.
371+
var chatViewModelOwnerID: String {
372+
let modeID = self.isLocalGatewayFixtureEnabled ? self.chatTransportModeID : "gateway"
373+
return "\(modeID)|\(self.chatTranscriptCacheGatewayID ?? "")|\(self.chatTranscriptCacheGeneration)"
374+
}
375+
365376
private var chatTranscriptCacheGeneration = 0
366377

367378
/// Offline transcript cache plus durable command outbox, both scoped to
@@ -379,6 +390,28 @@ final class NodeAppModel {
379390
return cache
380391
}
381392

393+
var hasVerifiedChatOfflineRoutingIdentity: Bool {
394+
self.chatTranscriptCacheGatewayID != nil &&
395+
self.chatDeliveryAgentId != nil &&
396+
self.chatSessionRoutingContract != nil
397+
}
398+
399+
func restoreChatSessionRoutingIdentityIfNeeded() async {
400+
guard !self.isLocalGatewayFixtureEnabled,
401+
self.chatSessionRoutingContract == nil,
402+
let store = self.makeChatOfflineStore(),
403+
let identity = await store.loadSessionRoutingIdentity(),
404+
self.chatTranscriptCacheGatewayID == store.gatewayID,
405+
self.chatSessionRoutingContract == nil
406+
else { return }
407+
self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: store.gatewayID)
408+
self.gatewaySessionScope = identity.scope
409+
self.mainSessionBaseKey = identity.mainSessionKey
410+
self.gatewayDefaultAgentId = identity.defaultAgentID
411+
self.talkMode.updateMainSessionKey(self.mainSessionKey)
412+
self.homeCanvasRevision &+= 1
413+
}
414+
382415
func loadCachedChatSessions() async -> [OpenClawChatSessionEntry] {
383416
guard let cache = self.makeChatOfflineStore() else { return [] }
384417
return await cache.loadSessions()
@@ -1006,14 +1039,23 @@ final class NodeAppModel {
10061039

10071040
private func refreshBrandingFromGateway(shouldApply: () -> Bool = { true }) async {
10081041
do {
1009-
let res = try await operatorGateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
1042+
guard let sourceGatewayID = self.chatTranscriptCacheGatewayID,
1043+
let sourceRoute = await operatorGateway.currentRoute(ifGatewayID: sourceGatewayID)
1044+
else { return }
1045+
let res = try await operatorGateway.request(
1046+
method: "config.get",
1047+
paramsJSON: "{}",
1048+
timeoutSeconds: 8,
1049+
ifCurrentRoute: sourceRoute)
10101050
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
10111051
guard let config = json["config"] as? [String: Any] else { return }
10121052
let session = config["session"] as? [String: Any]
10131053
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
1014-
guard shouldApply() else { return }
1054+
let scope = (session?["scope"] as? String) ?? "per-sender"
1055+
guard shouldApply(), self.chatTranscriptCacheGatewayID == sourceGatewayID else { return }
10151056
await MainActor.run {
10161057
self.mainSessionBaseKey = mainKey
1058+
self.gatewaySessionScope = scope
10171059
self.talkMode.updateMainSessionKey(self.mainSessionKey)
10181060
self.homeCanvasRevision &+= 1
10191061
}
@@ -1030,12 +1072,26 @@ final class NodeAppModel {
10301072

10311073
private func refreshAgentsFromGateway(shouldApply: () -> Bool = { true }) async {
10321074
do {
1033-
let res = try await operatorGateway.request(method: "agents.list", paramsJSON: "{}", timeoutSeconds: 8)
1075+
guard let sourceGatewayID = self.chatTranscriptCacheGatewayID,
1076+
let sourceStore = self.makeChatOfflineStore(),
1077+
sourceStore.gatewayID == sourceGatewayID,
1078+
let sourceRoute = await operatorGateway.currentRoute(ifGatewayID: sourceGatewayID)
1079+
else { return }
1080+
let res = try await operatorGateway.request(
1081+
method: "agents.list",
1082+
paramsJSON: "{}",
1083+
timeoutSeconds: 8,
1084+
ifCurrentRoute: sourceRoute)
10341085
let decoded = try JSONDecoder().decode(AgentsListResult.self, from: res)
1035-
guard shouldApply() else { return }
1086+
let routingIdentity = OpenClawChatSessionRoutingIdentity(
1087+
scope: decoded.scope.value as? String,
1088+
mainSessionKey: decoded.mainkey,
1089+
defaultAgentID: decoded.defaultid)
1090+
guard shouldApply(), self.chatTranscriptCacheGatewayID == sourceGatewayID else { return }
10361091
await MainActor.run {
10371092
self.gatewayDefaultAgentId = decoded.defaultid
10381093
self.gatewayAgents = decoded.agents
1094+
self.gatewaySessionScope = decoded.scope.value as? String
10391095
self.applyMainSessionKey(decoded.mainkey)
10401096

10411097
let selected = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1046,6 +1102,9 @@ final class NodeAppModel {
10461102
self.talkMode.updateMainSessionKey(self.mainSessionKey)
10471103
self.homeCanvasRevision &+= 1
10481104
}
1105+
if let routingIdentity {
1106+
await sourceStore.storeSessionRoutingIdentity(routingIdentity)
1107+
}
10491108
} catch {
10501109
// Best-effort only.
10511110
}
@@ -2344,6 +2403,26 @@ extension NodeAppModel {
23442403
return self.selectedOrDefaultAgentId
23452404
}
23462405

2406+
/// Verified routing owner for sends. Unlike `chatAgentId`, this has no
2407+
/// display fallback: a cold offline start must wait for persisted or
2408+
/// gateway-provided ownership before it can queue durable work.
2409+
var chatDeliveryAgentId: String? {
2410+
if let sessionAgentId = SessionKey.agentId(from: chatSessionKey) {
2411+
return sessionAgentId.lowercased()
2412+
}
2413+
let selected = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
2414+
if !selected.isEmpty { return selected.lowercased() }
2415+
let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
2416+
return defaultId.isEmpty ? nil : defaultId.lowercased()
2417+
}
2418+
2419+
var chatSessionRoutingContract: String? {
2420+
OpenClawChatSessionRoutingContract.make(
2421+
scope: self.gatewaySessionScope,
2422+
mainKey: self.mainSessionBaseKey,
2423+
defaultAgentID: self.gatewayDefaultAgentId)
2424+
}
2425+
23472426
var chatAgentName: String {
23482427
self.agentDisplayName(for: self.chatAgentId, fallback: "Main")
23492428
}
@@ -2636,7 +2715,6 @@ extension NodeAppModel {
26362715
self.gatewayConnected = false
26372716
setOperatorConnected(false)
26382717
self.talkMode.updateGatewayConnected(false)
2639-
self.mainSessionBaseKey = "main"
26402718
self.talkMode.updateMainSessionKey(self.mainSessionKey)
26412719
ShareGatewayRelaySettings.clearConfig()
26422720
showLocalCanvasOnDisconnect()
@@ -2684,12 +2762,17 @@ extension NodeAppModel {
26842762
self.voiceWakeSyncTask?.cancel()
26852763
self.voiceWakeSyncTask = nil
26862764
LiveActivityManager.shared.endActivity(reason: "new_gateway_connect")
2765+
self.mainSessionBaseKey = "main"
2766+
self.gatewaySessionScope = nil
26872767
self.gatewayDefaultAgentId = nil
26882768
self.gatewayAgents = []
26892769
self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID)
26902770
self.focusedChatSessionKey = nil
26912771
self.homeCanvasRevision &+= 1
26922772
self.apnsLastRegisteredTokenHex = nil
2773+
Task { [weak self] in
2774+
await self?.restoreChatSessionRoutingIdentityIfNeeded()
2775+
}
26932776
}
26942777

26952778
private func clearGatewayConnectionProblem() {
@@ -3476,7 +3559,8 @@ extension NodeAppModel {
34763559
self.gatewayConnected = false
34773560
self.setOperatorConnected(false)
34783561
self.talkMode.updateGatewayConnected(false)
3479-
self.mainSessionBaseKey = "main"
3562+
// Retain the last verified routing contract for offline
3563+
// capture; reconnect compares it with the live gateway before replay.
34803564
self.talkMode.updateMainSessionKey(self.mainSessionKey)
34813565
self.showLocalCanvasOnDisconnect()
34823566
}
@@ -3682,6 +3766,7 @@ extension NodeAppModel {
36823766
self.talkMode.setEnabled(false)
36833767
self.talkMode.statusText = "Demo mode only"
36843768
self.mainSessionBaseKey = "main"
3769+
self.gatewaySessionScope = "per-sender"
36853770
self.selectedAgentId = nil
36863771
self.gatewayDefaultAgentId = "main"
36873772
self.gatewayAgents = AppleReviewDemoMode.agents
@@ -3723,6 +3808,7 @@ extension NodeAppModel {
37233808
self.setOperatorConnected(true)
37243809
self.hasOperatorAdminScope = true
37253810
self.mainSessionBaseKey = "main"
3811+
self.gatewaySessionScope = "per-sender"
37263812
self.selectedAgentId = nil
37273813
self.gatewayDefaultAgentId = "main"
37283814
self.gatewayAgents = ScreenshotFixtureMode.agents
@@ -3887,7 +3973,7 @@ extension NodeAppModel {
38873973

38883974
let routeGeneration = self.gatewayRouteGeneration
38893975
guard let gatewayStableID = self.connectedGatewayID,
3890-
let nodeRoute = await nodeGateway.currentRoute(),
3976+
let nodeRoute = await self.nodeGateway.currentRoute(),
38913977
shouldContinue(),
38923978
self.isCurrentGatewayRoute(generation: routeGeneration, stableID: gatewayStableID)
38933979
else { return }

0 commit comments

Comments
 (0)