Skip to content

Commit 4b7661e

Browse files
authored
feat(ios): durable offline command outbox for chat sends (#100331)
* feat(ios): durable offline command outbox for chat sends Text messages sent while the gateway is unreachable queue in a durable per-gateway outbox (new outbox_commands table in the per-gateway chat cache SQLite store, schema v2) instead of failing. Queued bubbles render with visible Queued/Sending/Not sent states and flush strictly in createdAt order once transport health recovers; each command's client UUID rides as the send idempotency key, so at-least-once delivery plus gateway dedupe keeps the transcript exact. Contract summary: - Bounds: 50 queued commands per gateway; refused enqueues keep the draft. Queued rows older than 48h expire to failed("expired") rather than silently sending stale commands; tap-to-retry refreshes createdAt so an expired row can resend as new intent. - Failure taxonomy: transport-level failures keep rows queued without burning retry attempts (backoff ladder, then health drops so the reconnect machinery owns pacing); gateway rejections burn attempts and fail terminally after 3, with context-menu retry/delete. - Deletes are tombstoned synchronously and rechecked after the claim await so an active flush can never send a removed command; Delete is hidden while a bubble is already in flight; offline enqueue is guarded against double submit during the health probe. - Post-reconnect live sends route behind draining outbox rows (FIFO), and a cold open assumes a backlog until restore adopts durable rows. - Crash safety: 'sending' rows revert to 'queued' at startup; flushed turns are spliced into the session's cached transcript before their outbox row is deleted, and stale history snapshots cannot evict a just-flushed turn until a snapshot confirms it. - Per-gateway scoping and purge ride the transcript cache: one SQLite file per gateway; reset/forget drops the queue with the cache. Fixture/unpaired transports get no outbox. Part of #46664 * chore(ios): sync native i18n inventory * style(chat-ui): satisfy strict SwiftFormat lint (doc comment, scope blank line)
1 parent 0d509ab commit 4b7661e

13 files changed

Lines changed: 2559 additions & 271 deletions

apps/.i18n/native-source.json

Lines changed: 92 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7299,103 +7299,103 @@
72997299
},
73007300
{
73017301
"kind": "ui-modifier",
7302-
"line": 240,
7302+
"line": 238,
73037303
"path": "apps/ios/Sources/Design/ChatProTab.swift",
73047304
"source": "Opens Settings / Gateway",
73057305
"surface": "apple",
73067306
"id": "native.apple.d19c9577e0fdfea7"
73077307
},
73087308
{
73097309
"kind": "ui-call",
7310-
"line": 265,
7310+
"line": 263,
73117311
"path": "apps/ios/Sources/Design/ChatProTab.swift",
73127312
"source": "Export Transcript",
73137313
"surface": "apple",
73147314
"id": "native.apple.ced228bb4d20a726"
73157315
},
73167316
{
73177317
"kind": "ui-modifier",
7318-
"line": 275,
7318+
"line": 273,
73197319
"path": "apps/ios/Sources/Design/ChatProTab.swift",
73207320
"source": "Chat actions",
73217321
"surface": "apple",
73227322
"id": "native.apple.940e1f943750c94b"
73237323
},
73247324
{
73257325
"kind": "conditional-branch",
7326-
"line": 328,
7326+
"line": 326,
73277327
"path": "apps/ios/Sources/Design/ChatProTab.swift",
73287328
"source": "Connected",
73297329
"surface": "apple",
73307330
"id": "native.apple.b635acd93246d67b"
73317331
},
73327332
{
73337333
"kind": "conditional-branch",
7334-
"line": 328,
7334+
"line": 326,
73357335
"path": "apps/ios/Sources/Design/ChatProTab.swift",
73367336
"source": "Unavailable",
73377337
"surface": "apple",
73387338
"id": "native.apple.04934af85e3b9d6e"
73397339
},
73407340
{
73417341
"kind": "conditional-branch",
7342-
"line": 339,
7342+
"line": 337,
73437343
"path": "apps/ios/Sources/Design/ChatProTab.swift",
73447344
"source": "Connect to a gateway",
73457345
"surface": "apple",
73467346
"id": "native.apple.889fe76e02b8ad5d"
73477347
},
73487348
{
73497349
"kind": "conditional-branch",
7350-
"line": 339,
7350+
"line": 337,
73517351
"path": "apps/ios/Sources/Design/ChatProTab.swift",
73527352
"source": "Message \\(self.agentDisplayName)...",
73537353
"surface": "apple",
73547354
"id": "native.apple.e8173c7d24457256"
73557355
},
73567356
{
73577357
"kind": "ui-localized-call",
7358-
"line": 393,
7358+
"line": 391,
73597359
"path": "apps/ios/Sources/Design/ChatProTab.swift",
73607360
"source": "Check OpenClaw status",
73617361
"surface": "apple",
73627362
"id": "native.apple.14492e334b87e4f2"
73637363
},
73647364
{
73657365
"kind": "ui-localized-call",
7366-
"line": 394,
7366+
"line": 392,
73677367
"path": "apps/ios/Sources/Design/ChatProTab.swift",
73687368
"source": "Summarize the current OpenClaw status and tell me what needs attention.",
73697369
"surface": "apple",
73707370
"id": "native.apple.bbf7bd23da1e7865"
73717371
},
73727372
{
73737373
"kind": "ui-localized-call",
7374-
"line": 397,
7374+
"line": 395,
73757375
"path": "apps/ios/Sources/Design/ChatProTab.swift",
73767376
"source": "What can I control here?",
73777377
"surface": "apple",
73787378
"id": "native.apple.ea8cbb1b4230c652"
73797379
},
73807380
{
73817381
"kind": "ui-localized-call",
7382-
"line": 398,
7382+
"line": 396,
73837383
"path": "apps/ios/Sources/Design/ChatProTab.swift",
73847384
"source": "Show me which phone controls and device capabilities are available right now.",
73857385
"surface": "apple",
73867386
"id": "native.apple.451d25bdb3130ed0"
73877387
},
73887388
{
73897389
"kind": "ui-localized-call",
7390-
"line": 401,
7390+
"line": 399,
73917391
"path": "apps/ios/Sources/Design/ChatProTab.swift",
73927392
"source": "Help me start voice chat",
73937393
"surface": "apple",
73947394
"id": "native.apple.503ec402d08bd43a"
73957395
},
73967396
{
73977397
"kind": "ui-localized-call",
7398-
"line": 402,
7398+
"line": 400,
73997399
"path": "apps/ios/Sources/Design/ChatProTab.swift",
74007400
"source": "Help me start a realtime voice session from this phone.",
74017401
"surface": "apple",
@@ -11195,63 +11195,63 @@
1119511195
},
1119611196
{
1119711197
"kind": "conditional-branch",
11198-
"line": 2699,
11198+
"line": 2704,
1119911199
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
1120011200
"source": "Action required",
1120111201
"surface": "apple",
1120211202
"id": "native.apple.0d030e96f7738497"
1120311203
},
1120411204
{
1120511205
"kind": "conditional-branch",
11206-
"line": 2699,
11206+
"line": 2704,
1120711207
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
1120811208
"source": "Approval needed",
1120911209
"surface": "apple",
1121011210
"id": "native.apple.ce6a022159f2fb41"
1121111211
},
1121211212
{
1121311213
"kind": "conditional-branch",
11214-
"line": 3254,
11214+
"line": 3259,
1121511215
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
1121611216
"source": "Connecting…",
1121711217
"surface": "apple",
1121811218
"id": "native.apple.b0d9c1ba8ef7ec19"
1121911219
},
1122011220
{
1122111221
"kind": "conditional-branch",
11222-
"line": 3254,
11222+
"line": 3259,
1122311223
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
1122411224
"source": "Reconnecting…",
1122511225
"surface": "apple",
1122611226
"id": "native.apple.bd98e19b49309284"
1122711227
},
1122811228
{
1122911229
"kind": "conditional-branch",
11230-
"line": 3258,
11230+
"line": 3263,
1123111231
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
1123211232
"source": "Connecting...",
1123311233
"surface": "apple",
1123411234
"id": "native.apple.6504753f3621269e"
1123511235
},
1123611236
{
1123711237
"kind": "conditional-branch",
11238-
"line": 3258,
11238+
"line": 3263,
1123911239
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
1124011240
"source": "Reconnecting...",
1124111241
"surface": "apple",
1124211242
"id": "native.apple.c7fbe2a713d78714"
1124311243
},
1124411244
{
1124511245
"kind": "conditional-branch",
11246-
"line": 3546,
11246+
"line": 3551,
1124711247
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
1124811248
"source": "Connected",
1124911249
"surface": "apple",
1125011250
"id": "native.apple.c0a005f81d588867"
1125111251
},
1125211252
{
1125311253
"kind": "conditional-branch",
11254-
"line": 3546,
11254+
"line": 3551,
1125511255
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
1125611256
"source": "Offline",
1125711257
"surface": "apple",
@@ -20361,17 +20361,65 @@
2036120361
"surface": "apple",
2036220362
"id": "native.apple.e9914cfc93a514cb"
2036320363
},
20364+
{
20365+
"kind": "conditional-branch",
20366+
"line": 640,
20367+
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
20368+
"source": "Queued",
20369+
"surface": "apple",
20370+
"id": "native.apple.86695104895ebed8"
20371+
},
20372+
{
20373+
"kind": "conditional-branch",
20374+
"line": 642,
20375+
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
20376+
"source": "Sending…",
20377+
"surface": "apple",
20378+
"id": "native.apple.a85f179b35d39d8f"
20379+
},
20380+
{
20381+
"kind": "conditional-branch",
20382+
"line": 644,
20383+
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
20384+
"source": "Not sent",
20385+
"surface": "apple",
20386+
"id": "native.apple.2888a268bfa49e9e"
20387+
},
20388+
{
20389+
"kind": "conditional-branch",
20390+
"line": 662,
20391+
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
20392+
"source": "Queued, sends when reconnected",
20393+
"surface": "apple",
20394+
"id": "native.apple.593219d4c4479c59"
20395+
},
20396+
{
20397+
"kind": "conditional-branch",
20398+
"line": 664,
20399+
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
20400+
"source": "Sending",
20401+
"surface": "apple",
20402+
"id": "native.apple.00e673b9c5263c5c"
20403+
},
20404+
{
20405+
"kind": "conditional-branch",
20406+
"line": 666,
20407+
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
20408+
"source": "Not sent, touch and hold to retry or delete",
20409+
"surface": "apple",
20410+
"id": "native.apple.df1a81bd7a0bca36"
20411+
},
2036420412
{
2036520413
"kind": "ui-call",
20366-
"line": 710,
20414+
"line": 761,
2036720415
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
2036820416
"source": "Running tools…",
2036920417
"surface": "apple",
2037020418
"id": "native.apple.6b18ea1d85b5d3a2"
2037120419
},
2037220420
{
2037320421
"kind": "ui-call",
20374-
"line": 718,
20422+
"line": 769,
2037520423
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
2037620424
"source": "\\(display.emoji) \\(display.label)",
2037720425
"surface": "apple",
@@ -20387,39 +20435,55 @@
2038720435
},
2038820436
{
2038920437
"kind": "ui-call",
20390-
"line": 425,
20438+
"line": 402,
20439+
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
20440+
"source": "Retry Send",
20441+
"surface": "apple",
20442+
"id": "native.apple.5824df4efbf6c977"
20443+
},
20444+
{
20445+
"kind": "ui-call",
20446+
"line": 417,
20447+
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
20448+
"source": "Delete",
20449+
"surface": "apple",
20450+
"id": "native.apple.cdad35e9657fa693"
20451+
},
20452+
{
20453+
"kind": "ui-call",
20454+
"line": 471,
2039120455
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
2039220456
"source": "Jump to latest",
2039320457
"surface": "apple",
2039420458
"id": "native.apple.f84b674ad395b694"
2039520459
},
2039620460
{
2039720461
"kind": "ui-modifier",
20398-
"line": 436,
20462+
"line": 482,
2039920463
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
2040020464
"source": "Jump to latest reply",
2040120465
"surface": "apple",
2040220466
"id": "native.apple.429d825201b2cb85"
2040320467
},
2040420468
{
2040520469
"kind": "ui-named-argument",
20406-
"line": 456,
20470+
"line": 502,
2040720471
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
2040820472
"source": "Refresh",
2040920473
"surface": "apple",
2041020474
"id": "native.apple.9ab586b85834705c"
2041120475
},
2041220476
{
2041320477
"kind": "ui-call",
20414-
"line": 881,
20478+
"line": 927,
2041520479
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
2041620480
"source": "Loading chat",
2041720481
"surface": "apple",
2041820482
"id": "native.apple.28c2691c03030434"
2041920483
},
2042020484
{
2042120485
"kind": "ui-modifier",
20422-
"line": 961,
20486+
"line": 1007,
2042320487
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
2042420488
"source": "Dismiss",
2042520489
"surface": "apple",

apps/ios/Sources/Design/ChatProTab.swift

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -173,17 +173,7 @@ struct ChatProTab: View {
173173
self.viewModelTransportModeID = transportModeID
174174
self.viewModelTransportAgentID = transportAgentID
175175
self.viewModelAgentID = agentID
176-
self.viewModel = OpenClawChatViewModel(
177-
sessionKey: sessionKey,
178-
transport: self.appModel.makeChatTransport(),
179-
activeAgentId: agentID,
180-
transcriptCache: self.appModel.makeChatTranscriptCache(),
181-
onSessionChanged: { sessionKey in
182-
self.appModel.focusChatSession(sessionKey)
183-
},
184-
diagnosticsLog: { message in
185-
GatewayDiagnostics.log(message)
186-
})
176+
self.viewModel = self.makeChatViewModel(sessionKey: sessionKey, activeAgentId: agentID)
187177
return
188178
}
189179
if self.viewModelTransportModeID != transportModeID ||
@@ -193,23 +183,31 @@ struct ChatProTab: View {
193183
self.viewModelTransportModeID = transportModeID
194184
self.viewModelTransportAgentID = transportAgentID
195185
self.viewModelAgentID = agentID
196-
self.viewModel = OpenClawChatViewModel(
197-
sessionKey: sessionKey,
198-
transport: self.appModel.makeChatTransport(),
199-
activeAgentId: agentID,
200-
transcriptCache: self.appModel.makeChatTranscriptCache(),
201-
onSessionChanged: { sessionKey in
202-
self.appModel.focusChatSession(sessionKey)
203-
},
204-
diagnosticsLog: { message in
205-
GatewayDiagnostics.log(message)
206-
})
186+
self.viewModel = self.makeChatViewModel(sessionKey: sessionKey, activeAgentId: agentID)
207187
return
208188
}
209189
guard viewModel.sessionKey != sessionKey else { return }
210190
viewModel.syncSession(to: sessionKey)
211191
}
212192

193+
private func makeChatViewModel(sessionKey: String, activeAgentId: String? = nil) -> OpenClawChatViewModel {
194+
// One store instance backs both seams so the transcript cache and the
195+
// offline outbox share a single SQLite connection.
196+
let offlineStore = self.appModel.makeChatOfflineStore()
197+
return OpenClawChatViewModel(
198+
sessionKey: sessionKey,
199+
transport: self.appModel.makeChatTransport(),
200+
activeAgentId: activeAgentId,
201+
transcriptCache: offlineStore,
202+
outbox: offlineStore,
203+
onSessionChanged: { sessionKey in
204+
self.appModel.focusChatSession(sessionKey)
205+
},
206+
diagnosticsLog: { message in
207+
GatewayDiagnostics.log(message)
208+
})
209+
}
210+
213211
private var talkControl: OpenClawChatTalkControl {
214212
OpenClawChatTalkControl(
215213
isEnabled: self.appModel.talkMode.isEnabled,

0 commit comments

Comments
 (0)