Polish iOS onboarding and chat critique fixes (#99243)

* fix(ios): polish onboarding and empty chat

Co-authored-by: Josh <17843332+jcooley8@users.noreply.github.com>

* fix(ios): correlate pending chat echoes

* fix(ios): ship localized chat starters

* fix(ios): preserve chat echo identity

* fix(ios): adopt late correlated chat echoes

* fix(ios): reconcile reused chat runs

* fix(ios): preserve transformed chat history

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Josh
2026-07-03 21:38:56 -10:00
committed by GitHub
parent c4083e32f5
commit e43c72954a
18 changed files with 2933 additions and 257 deletions
+166 -78
View File
@@ -5859,7 +5859,7 @@
},
{
"kind": "conditional-branch",
"line": 303,
"line": 308,
"path": "apps/ios/Sources/Chat/AppleReviewDemoChatTransport.swift",
"source": "\\\"\\(trimmed)\\\"",
"surface": "apple",
@@ -5867,7 +5867,7 @@
},
{
"kind": "conditional-branch",
"line": 303,
"line": 308,
"path": "apps/ios/Sources/Chat/AppleReviewDemoChatTransport.swift",
"source": "that request",
"surface": "apple",
@@ -7201,9 +7201,17 @@
"surface": "apple",
"id": "native.apple.a97cdae7c8e13f0e"
},
{
"kind": "ui-localized-call",
"line": 100,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "What would you like to work on?",
"surface": "apple",
"id": "native.apple.202b97e93b938049"
},
{
"kind": "ui-call",
"line": 109,
"line": 111,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "The session attaches once the gateway is ready.",
"surface": "apple",
@@ -7211,7 +7219,7 @@
},
{
"kind": "ui-modifier",
"line": 185,
"line": 187,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Opens Settings / Gateway",
"surface": "apple",
@@ -7219,7 +7227,7 @@
},
{
"kind": "conditional-branch",
"line": 235,
"line": 237,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Connected",
"surface": "apple",
@@ -7227,7 +7235,7 @@
},
{
"kind": "conditional-branch",
"line": 235,
"line": 237,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Unavailable",
"surface": "apple",
@@ -7235,7 +7243,7 @@
},
{
"kind": "conditional-branch",
"line": 246,
"line": 248,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Connect to a gateway",
"surface": "apple",
@@ -7243,12 +7251,60 @@
},
{
"kind": "conditional-branch",
"line": 246,
"line": 248,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Message \\(self.agentDisplayName)...",
"surface": "apple",
"id": "native.apple.e8173c7d24457256"
},
{
"kind": "ui-localized-call",
"line": 298,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Check OpenClaw status",
"surface": "apple",
"id": "native.apple.14492e334b87e4f2"
},
{
"kind": "ui-localized-call",
"line": 299,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Summarize the current OpenClaw status and tell me what needs attention.",
"surface": "apple",
"id": "native.apple.bbf7bd23da1e7865"
},
{
"kind": "ui-localized-call",
"line": 302,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "What can I control here?",
"surface": "apple",
"id": "native.apple.ea8cbb1b4230c652"
},
{
"kind": "ui-localized-call",
"line": 303,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Show me which phone controls and device capabilities are available right now.",
"surface": "apple",
"id": "native.apple.451d25bdb3130ed0"
},
{
"kind": "ui-localized-call",
"line": 306,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Help me start voice chat",
"surface": "apple",
"id": "native.apple.503ec402d08bd43a"
},
{
"kind": "ui-localized-call",
"line": 307,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Help me start a realtime voice session from this phone.",
"surface": "apple",
"id": "native.apple.65e462758be96174"
},
{
"kind": "ui-call",
"line": 99,
@@ -10987,7 +11043,7 @@
},
{
"kind": "ui-call",
"line": 24,
"line": 25,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift",
"source": "Welcome to OpenClaw",
"surface": "apple",
@@ -10995,7 +11051,7 @@
},
{
"kind": "ui-call-concatenated",
"line": 36,
"line": 37,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift",
"source": "The connected agent can use capabilities you enable, including camera, microphone, photos, contacts, calendar, and location. Continue only if you trust the gateway and agent.",
"surface": "apple",
@@ -11003,7 +11059,7 @@
},
{
"kind": "ui-call",
"line": 44,
"line": 45,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift",
"source": "Security",
"surface": "apple",
@@ -11011,7 +11067,7 @@
},
{
"kind": "ui-call",
"line": 57,
"line": 58,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift",
"source": "You can change permissions later in Settings.",
"surface": "apple",
@@ -11019,7 +11075,7 @@
},
{
"kind": "ui-call",
"line": 63,
"line": 64,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift",
"source": "Continue",
"surface": "apple",
@@ -11027,7 +11083,7 @@
},
{
"kind": "ui-call",
"line": 143,
"line": 146,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift",
"source": "Connect Gateway",
"surface": "apple",
@@ -11035,15 +11091,47 @@
},
{
"kind": "ui-call",
"line": 147,
"line": 151,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift",
"source": "/pair qr",
"surface": "apple",
"id": "native.apple.9f65b2eadb6aee92"
},
{
"kind": "conditional-branch",
"line": 161,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift",
"source": "Copied",
"surface": "apple",
"id": "native.apple.fe3269c93d6f8524"
},
{
"kind": "conditional-branch",
"line": 161,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift",
"source": "Copy",
"surface": "apple",
"id": "native.apple.bad4a8110ca864e1"
},
{
"kind": "conditional-branch",
"line": 169,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift",
"source": "Copy pair command",
"surface": "apple",
"id": "native.apple.b6f3f5c300b28f17"
},
{
"kind": "conditional-branch",
"line": 169,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift",
"source": "Pair command copied",
"surface": "apple",
"id": "native.apple.7308cbcb1e40d70e"
},
{
"kind": "ui-call",
"line": 150,
"line": 174,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift",
"source": "Run this in your OpenClaw chat, then scan the code.",
"surface": "apple",
@@ -11051,7 +11139,7 @@
},
{
"kind": "ui-call",
"line": 162,
"line": 186,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift",
"source": "Scan QR Code",
"surface": "apple",
@@ -11059,7 +11147,7 @@
},
{
"kind": "ui-call",
"line": 171,
"line": 195,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift",
"source": "Set Up Manually",
"surface": "apple",
@@ -11115,7 +11203,7 @@
},
{
"kind": "ui-call",
"line": 144,
"line": 147,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Back",
"surface": "apple",
@@ -11123,7 +11211,7 @@
},
{
"kind": "ui-call",
"line": 152,
"line": 155,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Close",
"surface": "apple",
@@ -11131,7 +11219,7 @@
},
{
"kind": "ui-call",
"line": 167,
"line": 170,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Done",
"surface": "apple",
@@ -11139,7 +11227,7 @@
},
{
"kind": "ui-modifier",
"line": 175,
"line": 178,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "QR Scanner Unavailable",
"surface": "apple",
@@ -11147,7 +11235,7 @@
},
{
"kind": "ui-call",
"line": 180,
"line": 183,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "OK",
"surface": "apple",
@@ -11155,7 +11243,7 @@
},
{
"kind": "ui-call",
"line": 209,
"line": 212,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Scan QR Code",
"surface": "apple",
@@ -11163,7 +11251,7 @@
},
{
"kind": "ui-call",
"line": 216,
"line": 219,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Cancel",
"surface": "apple",
@@ -11171,7 +11259,7 @@
},
{
"kind": "ui-call",
"line": 223,
"line": 226,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Photos",
"surface": "apple",
@@ -11179,7 +11267,7 @@
},
{
"kind": "ui-named-argument",
"line": 347,
"line": 350,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "LAN or Tailscale host",
"surface": "apple",
@@ -11187,7 +11275,7 @@
},
{
"kind": "ui-named-argument",
"line": 355,
"line": 358,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "VPS with domain",
"surface": "apple",
@@ -11195,7 +11283,7 @@
},
{
"kind": "ui-named-argument",
"line": 366,
"line": 369,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "For local iOS app development",
"surface": "apple",
@@ -11203,7 +11291,7 @@
},
{
"kind": "ui-call",
"line": 381,
"line": 384,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Continue",
"surface": "apple",
@@ -11211,7 +11299,7 @@
},
{
"kind": "ui-call",
"line": 390,
"line": 393,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Developer mode",
"surface": "apple",
@@ -11219,7 +11307,7 @@
},
{
"kind": "conditional-branch",
"line": 418,
"line": 421,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Off",
"surface": "apple",
@@ -11227,7 +11315,7 @@
},
{
"kind": "conditional-branch",
"line": 418,
"line": 421,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "On",
"surface": "apple",
@@ -11235,7 +11323,7 @@
},
{
"kind": "ui-call",
"line": 425,
"line": 428,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Mode",
"surface": "apple",
@@ -11243,7 +11331,7 @@
},
{
"kind": "ui-call",
"line": 426,
"line": 429,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Discovery",
"surface": "apple",
@@ -11251,7 +11339,7 @@
},
{
"kind": "ui-call",
"line": 428,
"line": 431,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Progress",
"surface": "apple",
@@ -11259,7 +11347,7 @@
},
{
"kind": "ui-call",
"line": 430,
"line": 433,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Status",
"surface": "apple",
@@ -11267,7 +11355,7 @@
},
{
"kind": "ui-call",
"line": 449,
"line": 452,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Choose a mode first.",
"surface": "apple",
@@ -11275,7 +11363,7 @@
},
{
"kind": "ui-call",
"line": 454,
"line": 457,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Back to Mode Selection",
"surface": "apple",
@@ -11283,7 +11371,7 @@
},
{
"kind": "ui-call",
"line": 466,
"line": 469,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "No gateways found yet.",
"surface": "apple",
@@ -11291,7 +11379,7 @@
},
{
"kind": "ui-call",
"line": 491,
"line": 494,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Resolving…",
"surface": "apple",
@@ -11299,7 +11387,7 @@
},
{
"kind": "ui-call",
"line": 507,
"line": 510,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Restart Discovery",
"surface": "apple",
@@ -11307,7 +11395,7 @@
},
{
"kind": "ui-call",
"line": 513,
"line": 516,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Discovered Gateways",
"surface": "apple",
@@ -11315,7 +11403,7 @@
},
{
"kind": "ui-named-argument",
"line": 517,
"line": 520,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Manual Fallback",
"surface": "apple",
@@ -11323,7 +11411,7 @@
},
{
"kind": "ui-named-argument",
"line": 522,
"line": 525,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Domain Settings",
"surface": "apple",
@@ -11331,7 +11419,7 @@
},
{
"kind": "ui-call",
"line": 537,
"line": 540,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Developer Local",
"surface": "apple",
@@ -11339,7 +11427,7 @@
},
{
"kind": "ui-call",
"line": 540,
"line": 543,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Default host is localhost. Use your Mac LAN IP if simulator networking requires it.",
"surface": "apple",
@@ -11347,7 +11435,7 @@
},
{
"kind": "ui-call",
"line": 564,
"line": 567,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Gateway rejected credentials. Scan a fresh QR code or update token/password.",
"surface": "apple",
@@ -11355,7 +11443,7 @@
},
{
"kind": "ui-call",
"line": 568,
"line": 571,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Auth token looks valid.",
"surface": "apple",
@@ -11363,7 +11451,7 @@
},
{
"kind": "ui-call",
"line": 582,
"line": 585,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Resume After Approval",
"surface": "apple",
@@ -11371,7 +11459,7 @@
},
{
"kind": "ui-call",
"line": 588,
"line": 591,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Pairing Approval",
"surface": "apple",
@@ -11379,7 +11467,7 @@
},
{
"kind": "ui-call-concatenated",
"line": 598,
"line": 601,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Approve this device on the gateway.\n1) `\\(commandLine)`\n2) `/pair approve` in your OpenClaw chat\n\\(requestLine)\nOpenClaw will also retry automatically when you return to this app.",
"surface": "apple",
@@ -11387,7 +11475,7 @@
},
{
"kind": "ui-call",
"line": 612,
"line": 615,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Scan QR Code Again",
"surface": "apple",
@@ -11395,7 +11483,7 @@
},
{
"kind": "ui-call",
"line": 625,
"line": 628,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Retry Connection",
"surface": "apple",
@@ -11403,15 +11491,15 @@
},
{
"kind": "ui-call",
"line": 666,
"line": 661,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Open OpenClaw",
"source": "Go to Chat",
"surface": "apple",
"id": "native.apple.f50052c1d760ec10"
"id": "native.apple.e674edbcb002aec6"
},
{
"kind": "ui-call",
"line": 680,
"line": 675,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Paste setup code",
"surface": "apple",
@@ -11419,7 +11507,7 @@
},
{
"kind": "ui-call",
"line": 695,
"line": 690,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Applying...",
"surface": "apple",
@@ -11427,7 +11515,7 @@
},
{
"kind": "ui-call",
"line": 699,
"line": 694,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Apply Setup Code",
"surface": "apple",
@@ -11435,7 +11523,7 @@
},
{
"kind": "ui-call",
"line": 714,
"line": 709,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Setup Code",
"surface": "apple",
@@ -11443,7 +11531,7 @@
},
{
"kind": "ui-call",
"line": 717,
"line": 712,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Use this if you received a setup code instead of a QR code.",
"surface": "apple",
@@ -11451,7 +11539,7 @@
},
{
"kind": "ui-call",
"line": 724,
"line": 719,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Host",
"surface": "apple",
@@ -11459,7 +11547,7 @@
},
{
"kind": "ui-call",
"line": 728,
"line": 723,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Port",
"surface": "apple",
@@ -11467,7 +11555,7 @@
},
{
"kind": "ui-call",
"line": 731,
"line": 726,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Use TLS",
"surface": "apple",
@@ -11475,7 +11563,7 @@
},
{
"kind": "ui-call",
"line": 732,
"line": 727,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Discovery Domain (optional)",
"surface": "apple",
@@ -11483,7 +11571,7 @@
},
{
"kind": "ui-call",
"line": 737,
"line": 732,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Gateway Auth Token",
"surface": "apple",
@@ -11491,7 +11579,7 @@
},
{
"kind": "ui-call",
"line": 740,
"line": 735,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Gateway Password",
"surface": "apple",
@@ -11499,7 +11587,7 @@
},
{
"kind": "ui-call",
"line": 784,
"line": 779,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Connecting…",
"surface": "apple",
@@ -11659,7 +11747,7 @@
},
{
"kind": "conditional-branch",
"line": 990,
"line": 994,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Gateway needs attention",
"surface": "apple",
@@ -11667,7 +11755,7 @@
},
{
"kind": "conditional-branch",
"line": 990,
"line": 994,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "OpenClaw iOS",
"surface": "apple",
@@ -11675,7 +11763,7 @@
},
{
"kind": "conditional-branch",
"line": 1026,
"line": 1030,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Available",
"surface": "apple",
@@ -11683,7 +11771,7 @@
},
{
"kind": "conditional-branch",
"line": 1026,
"line": 1030,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Gateway default",
"surface": "apple",
@@ -19731,7 +19819,7 @@
},
{
"kind": "ui-call",
"line": 404,
"line": 425,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Jump to latest",
"surface": "apple",
@@ -19739,7 +19827,7 @@
},
{
"kind": "ui-modifier",
"line": 415,
"line": 436,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Jump to latest reply",
"surface": "apple",
@@ -19747,7 +19835,7 @@
},
{
"kind": "ui-named-argument",
"line": 435,
"line": 456,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Refresh",
"surface": "apple",
@@ -19755,7 +19843,7 @@
},
{
"kind": "ui-call",
"line": 832,
"line": 881,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Loading chat",
"surface": "apple",
@@ -19763,7 +19851,7 @@
},
{
"kind": "ui-modifier",
"line": 912,
"line": 961,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Dismiss",
"surface": "apple",
+952
View File
@@ -681,6 +681,142 @@
}
}
},
"Check OpenClaw status": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Check OpenClaw status"
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "检查 OpenClaw 状态"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "檢查 OpenClaw 狀態"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Verificar status do OpenClaw"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "OpenClaw-Status prüfen"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Comprobar el estado de OpenClaw"
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "OpenClawのステータスを確認"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "OpenClaw 상태 확인"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Vérifier l’état dOpenClaw"
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "OpenClaw की स्थिति जाँचें"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "التحقق من حالة OpenClaw"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Controlla lo stato di OpenClaw"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "OpenClaw durumunu kontrol et"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Перевірити стан OpenClaw"
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Periksa status OpenClaw"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Sprawdź stan OpenClaw"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "ตรวจสอบสถานะ OpenClaw"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Kiểm tra trạng thái OpenClaw"
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "OpenClaw-status controleren"
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "بررسی وضعیت OpenClaw"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Проверить состояние OpenClaw"
}
},
"sv": {
"stringUnit": {
"state": "translated",
"value": "Kontrollera OpenClaw-status"
}
}
}
},
"Close": {
"localizations": {
"en": {
@@ -2177,6 +2313,278 @@
}
}
},
"Help me start a realtime voice session from this phone.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Help me start a realtime voice session from this phone."
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "帮我从这部手机开始实时语音会话。"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "幫我從這支手機開始即時語音工作階段。"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Ajude-me a iniciar uma sessão de voz em tempo real neste telefone."
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Hilf mir, auf diesem Telefon eine Echtzeit-Sprachsitzung zu starten."
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Ayúdame a iniciar una sesión de voz en tiempo real desde este teléfono."
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "このスマートフォンからリアルタイム音声セッションを開始するのを手伝ってください。"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "이 휴대전화에서 실시간 음성 세션을 시작하도록 도와주세요."
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Aidez-moi à démarrer une session vocale en temps réel depuis ce téléphone."
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "इस फ़ोन से रीयल-टाइम वॉइस सत्र शुरू करने में मेरी मदद करें।"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "ساعدني في بدء جلسة صوتية في الوقت الفعلي من هذا الهاتف."
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Aiutami ad avviare una sessione vocale in tempo reale da questo telefono."
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Bu telefondan gerçek zamanlı bir sesli oturum başlatmama yardım et."
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Допоможи мені почати голосовий сеанс у реальному часі з цього телефона."
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Bantu saya memulai sesi suara waktu nyata dari ponsel ini."
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Pomóż mi rozpocząć sesję głosową w czasie rzeczywistym na tym telefonie."
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "ช่วยฉันเริ่มเซสชันเสียงแบบเรียลไทม์จากโทรศัพท์เครื่องนี้"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Giúp tôi bắt đầu một phiên thoại theo thời gian thực từ điện thoại này."
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "Help me vanaf deze telefoon een realtime spraaksessie te starten."
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "به من کمک کن از این تلفن یک جلسه صوتی بلادرنگ را شروع کنم."
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Помоги мне начать голосовой сеанс в реальном времени с этого телефона."
}
},
"sv": {
"stringUnit": {
"state": "translated",
"value": "Hjälp mig att starta en röstsession i realtid från den här telefonen."
}
}
}
},
"Help me start voice chat": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Help me start voice chat"
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "帮我开始语音聊天"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "幫我開始語音聊天"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Ajude-me a iniciar um chat por voz"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Hilf mir, einen Sprachchat zu starten"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Ayúdame a iniciar un chat de voz"
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "音声チャットの開始を手伝って"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "음성 채팅 시작 도와줘"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Aidez-moi à démarrer un chat vocal"
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "वॉइस चैट शुरू करने में मेरी मदद करें"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "ساعدني في بدء محادثة صوتية"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Aiutami ad avviare una chat vocale"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Sesli sohbet başlatmama yardım et"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Допоможи мені почати голосовий чат"
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Bantu saya memulai chat suara"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Pomóż mi rozpocząć czat głosowy"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "ช่วยฉันเริ่มแชทด้วยเสียง"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Giúp tôi bắt đầu trò chuyện thoại"
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "Help me een spraakchat te starten"
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "به من کمک کن گفت‌وگوی صوتی را شروع کنم"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Помоги мне начать голосовой чат"
}
},
"sv": {
"stringUnit": {
"state": "translated",
"value": "Hjälp mig att starta en röstchatt"
}
}
}
},
"Invalid saved gateway URL.": {
"localizations": {
"en": {
@@ -4761,6 +5169,278 @@
}
}
},
"Show me which phone controls and device capabilities are available right now.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Show me which phone controls and device capabilities are available right now."
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "告诉我目前可以使用哪些手机控制功能和设备能力。"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "告訴我目前可以使用哪些手機控制功能和裝置能力。"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Mostre quais controles do telefone e recursos do dispositivo estão disponíveis agora."
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Zeige mir, welche Telefonsteuerungen und Gerätefunktionen gerade verfügbar sind."
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Muéstrame qué controles del teléfono y funciones del dispositivo están disponibles ahora."
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "現在利用できるスマートフォンの操作機能とデバイス機能を教えてください。"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "지금 사용할 수 있는 휴대전화 제어 기능과 기기 기능을 보여 주세요."
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Montrez-moi les commandes du téléphone et les fonctionnalités de lappareil actuellement disponibles."
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "मुझे दिखाएं कि अभी कौन-से फ़ोन नियंत्रण और डिवाइस क्षमताएं उपलब्ध हैं।"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "اعرض لي عناصر التحكم في الهاتف وإمكانات الجهاز المتاحة الآن."
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Mostrami quali controlli del telefono e funzionalità del dispositivo sono disponibili ora."
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Şu anda hangi telefon kontrollerinin ve cihaz özelliklerinin kullanılabildiğini göster."
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Покажи, які елементи керування телефоном і можливості пристрою доступні зараз."
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Tunjukkan kontrol ponsel dan kemampuan perangkat yang tersedia saat ini."
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Pokaż mi, które funkcje sterowania telefonem i możliwości urządzenia są teraz dostępne."
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "แสดงให้ฉันเห็นว่าตอนนี้มีการควบคุมโทรศัพท์และความสามารถของอุปกรณ์ใดบ้าง"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Cho tôi biết hiện có những tính năng điều khiển điện thoại và khả năng thiết bị nào."
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "Laat me zien welke telefoonbediening en apparaatfuncties nu beschikbaar zijn."
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "نشان بده اکنون کدام کنترل‌های تلفن و قابلیت‌های دستگاه در دسترس هستند."
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Покажи, какие элементы управления телефоном и возможности устройства сейчас доступны."
}
},
"sv": {
"stringUnit": {
"state": "translated",
"value": "Visa vilka telefonkontroller och enhetsfunktioner som är tillgängliga just nu."
}
}
}
},
"Summarize the current OpenClaw status and tell me what needs attention.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Summarize the current OpenClaw status and tell me what needs attention."
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "总结当前的 OpenClaw 状态,并告诉我哪些方面需要注意。"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "總結目前的 OpenClaw 狀態,並告訴我哪些方面需要注意。"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Resuma o status atual do OpenClaw e diga o que precisa de atenção."
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Fasse den aktuellen OpenClaw-Status zusammen und sage mir, was Aufmerksamkeit erfordert."
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Resume el estado actual de OpenClaw y dime qué necesita atención."
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "現在のOpenClawの状態を要約し、注意が必要な点を教えてください。"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "현재 OpenClaw 상태를 요약하고 주의가 필요한 항목을 알려 주세요."
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Résumez l’état actuel dOpenClaw et indiquez-moi ce qui nécessite mon attention."
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "OpenClaw की मौजूदा स्थिति का सारांश दें और बताएं कि किन चीज़ों पर ध्यान देने की ज़रूरत है।"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "لخّص حالة OpenClaw الحالية وأخبرني بما يحتاج إلى الاهتمام."
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Riassumi lo stato attuale di OpenClaw e dimmi cosa richiede attenzione."
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Mevcut OpenClaw durumunu özetle ve nelerin ilgilenilmesi gerektiğini söyle."
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Підсумуй поточний стан OpenClaw і скажи, що потребує уваги."
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Ringkas status OpenClaw saat ini dan beri tahu saya apa yang perlu diperhatikan."
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Podsumuj bieżący stan OpenClaw i powiedz mi, co wymaga uwagi."
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "สรุปสถานะ OpenClaw ปัจจุบันและบอกฉันว่ามีอะไรที่ต้องตรวจสอบ"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Tóm tắt trạng thái OpenClaw hiện tại và cho tôi biết những gì cần chú ý."
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "Vat de huidige OpenClaw-status samen en vertel me wat aandacht nodig heeft."
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "وضعیت فعلی OpenClaw را خلاصه کن و بگو چه چیزهایی نیاز به توجه دارند."
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Подведи итог текущего состояния OpenClaw и скажи, что требует внимания."
}
},
"sv": {
"stringUnit": {
"state": "translated",
"value": "Sammanfatta aktuell OpenClaw-status och berätta vad som behöver uppmärksammas."
}
}
}
},
"Talk": {
"localizations": {
"en": {
@@ -5441,6 +6121,278 @@
}
}
},
"What can I control here?": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "What can I control here?"
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "我可以在这里控制什么?"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "我可以在這裡控制什麼?"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "O que posso controlar aqui?"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Was kann ich hier steuern?"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "¿Qué puedo controlar aquí?"
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "ここでは何を操作できますか?"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "여기서 무엇을 제어할 수 있나요?"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Que puis-je contrôler ici ?"
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "मैं यहाँ क्या नियंत्रित कर सकता हूँ?"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "ما الذي يمكنني التحكم فيه هنا؟"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Cosa posso controllare qui?"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Burada neleri kontrol edebilirim?"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Чим я можу керувати тут?"
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Apa yang dapat saya kontrol di sini?"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Czym mogę tutaj sterować?"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "ฉันควบคุมอะไรที่นี่ได้บ้าง"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Tôi có thể điều khiển gì ở đây?"
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "Wat kan ik hier bedienen?"
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "اینجا چه چیزهایی را می‌توانم کنترل کنم؟"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Чем я могу управлять здесь?"
}
},
"sv": {
"stringUnit": {
"state": "translated",
"value": "Vad kan jag styra här?"
}
}
}
},
"What would you like to work on?": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "What would you like to work on?"
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "你想处理什么?"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "你想處理什麼?"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Em que você gostaria de trabalhar?"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Woran möchtest du arbeiten?"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "¿En qué te gustaría trabajar?"
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "何に取り組みますか?"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "무엇을 진행하고 싶으신가요?"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Sur quoi souhaitez-vous travailler ?"
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "आप किस पर काम करना चाहेंगे?"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "ما الذي تود العمل عليه؟"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Su cosa vuoi lavorare?"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Ne üzerinde çalışmak istersiniz?"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Над чим ви хочете попрацювати?"
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Apa yang ingin Anda kerjakan?"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Nad czym chcesz popracować?"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "คุณอยากทำอะไร"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Bạn muốn làm việc gì?"
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "Waaraan wil je werken?"
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "می‌خواهید روی چه چیزی کار کنید؟"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Над чем вы хотите поработать?"
}
},
"sv": {
"stringUnit": {
"state": "translated",
"value": "Vad vill du arbeta med?"
}
}
}
},
"Writing": {
"localizations": {
"en": {
@@ -80,9 +80,9 @@ struct LocalChatFixture {
modelID: "gpt-5.5",
modelName: "GPT-5.5",
responsePrefix: "OpenClaw is connected to your gateway.",
seedMessages: [
"Ready when you are. I can check a project, coordinate an agent, or prepare the next step.",
],
seedMessages: ProcessInfo.processInfo.arguments.contains("--openclaw-empty-chat-fixture")
? []
: ["Ready when you are. I can check a project, coordinate an agent, or prepare the next step."],
agents: [
AgentSummary(
id: "main",
@@ -298,7 +298,12 @@ private actor LocalFixtureChatStore {
func sendMessage(sessionKey _: String, message: String, runId: String) throws -> OpenClawChatSendResponse {
let now = Date().timeIntervalSince1970 * 1000
self.messages.append(Self.message(role: "user", text: message, timestamp: now))
self.messages.append(
Self.message(
role: "user",
text: message,
timestamp: now,
idempotencyKey: "\(runId):user"))
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
let subject = trimmed.isEmpty ? "that request" : "\"\(trimmed)\""
self.messages.append(
@@ -377,7 +382,12 @@ private actor LocalFixtureChatStore {
}
}
private static func message(role: String, text: String, timestamp: Double) -> OpenClawChatMessage {
private static func message(
role: String,
text: String,
timestamp: Double,
idempotencyKey: String? = nil) -> OpenClawChatMessage
{
OpenClawChatMessage(
role: role,
content: [
@@ -388,7 +398,8 @@ private actor LocalFixtureChatStore {
fileName: nil,
content: nil),
],
timestamp: timestamp)
timestamp: timestamp,
idempotencyKey: idempotencyKey)
}
private static func normalizedSessionKey(_ value: String, fallback: String) -> String {
+24 -1
View File
@@ -97,6 +97,8 @@ struct ChatProTab: View {
composerChrome: .clean,
isComposerEnabled: self.gatewayConnected,
messagePlaceholder: self.messagePlaceholder,
emptyAssistantIntro: String(localized: "What would you like to work on?"),
emptyAssistantPrompts: Self.emptyAssistantPrompts,
talkControl: self.talkControl)
// iMessage-style grey bubbles for agent replies in the clean chrome.
.environment(\.openClawAssistantBubblesInCleanChrome, true)
@@ -270,7 +272,7 @@ struct ChatProTab: View {
private var agentBadge: String {
if let identity = activeAgent?.identity,
let emoji = identity["emoji"]?.value as? String,
let normalizedEmoji = normalized(emoji)
let normalizedEmoji = Self.normalizedBadgeEmoji(emoji)
{
return normalizedEmoji
}
@@ -284,6 +286,27 @@ struct ChatProTab: View {
return "OC"
}
nonisolated static func normalizedBadgeEmoji(_ value: String?) -> String? {
guard let value else { return nil }
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
return normalized.isEmpty || normalized == "?" ? nil : normalized
}
nonisolated static let emptyAssistantPrompts: [OpenClawChatView.StarterPrompt] = [
OpenClawChatView.StarterPrompt(
id: "summarize-status",
title: String(localized: "Check OpenClaw status"),
prompt: String(localized: "Summarize the current OpenClaw status and tell me what needs attention.")),
OpenClawChatView.StarterPrompt(
id: "show-controls",
title: String(localized: "What can I control here?"),
prompt: String(localized: "Show me which phone controls and device capabilities are available right now.")),
OpenClawChatView.StarterPrompt(
id: "start-voice",
title: String(localized: "Help me start voice chat"),
prompt: String(localized: "Help me start a realtime voice session from this phone.")),
]
private func normalized(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1,4 +1,5 @@
import SwiftUI
import UIKit
private enum OnboardingLayout {
static let contentMaxWidth: CGFloat = 680
@@ -131,6 +132,8 @@ struct OnboardingWelcomeStep: View {
let onScanQRCode: () -> Void
let onManualSetup: () -> Void
@State private var pairCommandCopied = false
var body: some View {
VStack(spacing: 0) {
Spacer()
@@ -144,8 +147,29 @@ struct OnboardingWelcomeStep: View {
.font(OpenClawType.title1)
.padding(.bottom, 12)
KeycapText("/pair qr")
.padding(.bottom, 10)
HStack(spacing: 8) {
KeycapText("/pair qr")
Button {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
UIPasteboard.general.string = "/pair qr"
self.pairCommandCopied = true
}
} label: {
Label(
self.pairCommandCopied ? "Copied" : "Copy",
systemImage: self.pairCommandCopied ? "checkmark" : "doc.on.doc")
.font(OpenClawType.captionSemiBold)
}
.buttonStyle(.bordered)
.controlSize(.small)
.tint(.primary)
.animation(nil, value: self.pairCommandCopied)
.accessibilityLabel(self.pairCommandCopied ? "Pair command copied" : "Copy pair command")
.accessibilityIdentifier("onboarding-copy-pair-command")
}
.padding(.bottom, 10)
Text("Run this in your OpenClaw chat, then scan the code.")
.font(OpenClawType.subhead)
@@ -75,15 +75,18 @@ struct OnboardingWizardView: View {
let allowSkip: Bool
let onRequestLocalNetworkAccess: (String) -> Void
let onClose: () -> Void
let onComplete: () -> Void
init(
allowSkip: Bool,
onRequestLocalNetworkAccess: @escaping (String) -> Void,
onClose: @escaping () -> Void)
onClose: @escaping () -> Void,
onComplete: @escaping () -> Void)
{
self.allowSkip = allowSkip
self.onRequestLocalNetworkAccess = onRequestLocalNetworkAccess
self.onClose = onClose
self.onComplete = onComplete
_step = State(
initialValue: OnboardingStateStore.shouldPresentFirstRunIntro() ? .intro : .welcome)
}
@@ -646,24 +649,16 @@ struct OnboardingWizardView: View {
.foregroundStyle(OpenClawBrand.textPrimary)
.padding(.bottom, 8)
let server = self.appModel.gatewayServerName ?? "gateway"
Text(server)
Text(self.successEndpoint)
.font(OpenClawType.subhead)
.foregroundStyle(.secondary)
.padding(.bottom, 4)
if let addr = self.appModel.gatewayRemoteAddress {
Text(addr)
.font(OpenClawType.subhead)
.foregroundStyle(.secondary)
}
Spacer()
Button {
self.onClose()
self.onComplete()
} label: {
Text("Open OpenClaw")
Label("Go to Chat", systemImage: "bubble.left.and.bubble.right.fill")
.font(OpenClawType.headline)
}
.font(OpenClawType.headline)
@@ -997,6 +992,15 @@ extension OnboardingWizardView {
return !host.isEmpty && self.manualPort > 0 && self.manualPort <= 65535
}
private var successEndpoint: String {
let serverName = self.appModel.gatewayServerName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !serverName.isEmpty {
return serverName
}
let remoteAddress = self.appModel.gatewayRemoteAddress?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return remoteAddress.isEmpty ? "gateway" : remoteAddress
}
private func initializeState() {
if self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if let last = GatewaySettingsStore.loadLastGatewayConnection() {
+4
View File
@@ -900,6 +900,10 @@ struct RootTabs: View {
},
onClose: {
self.showOnboarding = false
},
onComplete: {
self.selectSidebarDestination(.chat)
self.showOnboarding = false
})
.environment(self.appModel)
.environment(self.voiceWake)
@@ -41,4 +41,16 @@ import Testing
#expect(ChatProTab.gatewayPillTitle(state: .connected, isGatewayUsable: true) == "Connected")
#expect(ChatProTab.gatewayPillTitle(state: .connected, isGatewayUsable: false) == "Unavailable")
}
@Test func `chat agent badge rejects placeholder question mark`() {
#expect(ChatProTab.normalizedBadgeEmoji(" 🦞 ") == "🦞")
#expect(ChatProTab.normalizedBadgeEmoji("?") == nil)
#expect(ChatProTab.normalizedBadgeEmoji(" ") == nil)
#expect(ChatProTab.normalizedBadgeEmoji(nil) == nil)
}
@Test func `chat starter prompts stay stable and actionable`() {
#expect(ChatProTab.emptyAssistantPrompts.map(\.id) == ["summarize-status", "show-controls", "start-voice"])
#expect(ChatProTab.emptyAssistantPrompts.allSatisfy { !$0.title.isEmpty && !$0.prompt.isEmpty })
}
}
@@ -1,4 +1,5 @@
import Foundation
import OpenClawChatUI
import OpenClawKit
import OpenClawProtocol
import Testing
@@ -175,3 +176,23 @@ import Testing
#expect(mapped == nil)
}
}
@Suite struct LocalFixtureChatTransportTests {
@Test func sentUserTurnCarriesGatewayIdempotencyMetadata() async throws {
let transport = LocalFixtureChatTransport(fixture: .appleReviewDemo)
_ = try await transport.sendMessage(
sessionKey: "main",
message: "hello",
thinking: "auto",
idempotencyKey: "fixture-run",
attachments: [])
let history = try await transport.requestHistory(sessionKey: "main")
let decoded = try #require(history.messages).compactMap { payload -> OpenClawChatMessage? in
guard let data = try? JSONEncoder().encode(payload) else { return nil }
return try? JSONDecoder().decode(OpenClawChatMessage.self, from: data)
}
#expect(decoded.last(where: { $0.role == "user" })?.idempotencyKey == "fixture-run:user")
}
}
+101 -2
View File
@@ -293,6 +293,103 @@ final class OpenClawSnapshotUITests: XCTestCase {
self.attachScreenshot(named: "chat-light")
}
func testEmptyChatStarterPromptSendsMessage() throws {
self.launchApp(
for: ScreenshotTarget(
initialTab: "chat",
initialDestination: "chat",
name: "chat-empty-starters"),
additionalArguments: ["--openclaw-empty-chat-fixture"])
let starter = try XCTUnwrap(self.app?.buttons["chat-starter-summarize-status"])
XCTAssertTrue(starter.waitForExistence(timeout: 8))
XCTAssertTrue(self.app?.staticTexts["What would you like to work on?"].exists == true)
self.attachScreenshot(named: "chat-empty-starters")
starter.tap()
let sentText = "Summarize the current OpenClaw status and tell me what needs attention."
let sentRows = self.app?.staticTexts.matching(NSPredicate(format: "label == %@", sentText))
XCTAssertTrue(sentRows?.firstMatch.waitForExistence(timeout: 5) == true)
XCTAssertEqual(sentRows?.count, 1)
XCTAssertTrue(
self.app?.staticTexts.matching(NSPredicate(format: "label CONTAINS %@", "I can help with"))
.firstMatch.waitForExistence(timeout: 5) == true)
self.attachScreenshot(named: "chat-starter-response")
}
func testEmptyChatStarterPromptsLocalizeInGerman() throws {
self.launchApp(
for: ScreenshotTarget(
initialTab: "chat",
initialDestination: "chat",
name: "chat-empty-starters-german"),
additionalArguments: [
"--openclaw-empty-chat-fixture",
"-AppleLanguages",
"(de)",
"-AppleLocale",
"de_DE",
])
XCTAssertTrue(self.app?.staticTexts["Woran möchtest du arbeiten?"].waitForExistence(timeout: 8) == true)
let starter = try XCTUnwrap(self.app?.buttons["OpenClaw-Status prüfen"])
XCTAssertTrue(starter.exists)
starter.tap()
XCTAssertTrue(
self.app?.staticTexts[
"Fasse den aktuellen OpenClaw-Status zusammen und sage mir, was Aufmerksamkeit erfordert."
].waitForExistence(timeout: 5) == true)
self.attachScreenshot(named: "chat-empty-starters-german")
}
func testOnboardingPairCommandAndCompletionOpenChat() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone onboarding proof only")
self.addUIInterruptionMonitor(withDescription: "Local network access") { alert in
guard alert.buttons["Allow"].exists else { return false }
alert.buttons["Allow"].tap()
return true
}
let app = XCUIApplication()
setupSnapshot(app)
app.launchArguments += [
"--openclaw-reset-onboarding",
"--openclaw-initial-tab",
"settings",
"--openclaw-initial-destination",
"settings",
]
app.launch()
self.app = app
XCTAssertTrue(app.buttons["Continue"].waitForExistence(timeout: 8))
app.buttons["Continue"].tap()
app.tap()
let copyPairCommand = app.buttons["onboarding-copy-pair-command"]
XCTAssertTrue(copyPairCommand.waitForExistence(timeout: 8))
copyPairCommand.tap()
XCTAssertEqual(copyPairCommand.label, "Pair command copied")
self.attachScreenshot(named: "onboarding-copy-pair-command")
app.buttons["Set Up Manually"].tap()
let setupCode = app.textFields["Paste setup code"]
XCTAssertTrue(setupCode.waitForExistence(timeout: 5))
setupCode.tap()
setupCode.typeText("APPLE-REVIEW-DEMO")
app.buttons["Done"].tap()
app.buttons["Apply Setup Code"].tap()
XCTAssertTrue(app.staticTexts["Connected"].waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts["Apple Review Demo Gateway"].exists)
XCTAssertFalse(app.staticTexts["Local demo mode"].exists)
self.attachScreenshot(named: "onboarding-connected-go-to-chat")
app.buttons["Go to Chat"].tap()
XCTAssertTrue(app.tabBars.buttons["Chat"].waitForExistence(timeout: 5))
XCTAssertTrue(app.tabBars.buttons["Chat"].isSelected)
}
func testTalkUsesNativeControls() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone Talk controls only")
self.launchApp(for: ScreenshotTarget(
@@ -602,7 +699,8 @@ final class OpenClawSnapshotUITests: XCTestCase {
private func launchApp(
for target: ScreenshotTarget,
appearance: String? = "dark",
screenshotMode: Bool = true)
screenshotMode: Bool = true,
additionalArguments: [String] = [])
{
self.app?.terminate()
@@ -619,6 +717,7 @@ final class OpenClawSnapshotUITests: XCTestCase {
if screenshotMode {
app.launchArguments.append("--openclaw-screenshot-mode")
}
app.launchArguments += additionalArguments
if let appearance {
app.launchArguments += ["--openclaw-appearance", appearance]
}
@@ -684,7 +783,7 @@ final class OpenClawSnapshotUITests: XCTestCase {
app.buttons["Apply Setup Code"].tap()
XCTAssertTrue(app.staticTexts["Connected"].waitForExistence(timeout: 45))
app.buttons["Open OpenClaw"].tap()
app.buttons["Go to Chat"].tap()
return app
}
+4 -4
View File
@@ -65,8 +65,8 @@ targets:
buildPhase: resources
- path: Sources/Fonts
buildPhase: resources
resources:
- path: Resources/Localizable.xcstrings
buildPhase: resources
dependencies:
- target: OpenClawShareExtension
embed: true
@@ -201,8 +201,8 @@ targets:
Release: Signing.xcconfig
sources:
- path: ShareExtension
resources:
- path: Resources/Localizable.xcstrings
buildPhase: resources
dependencies:
- package: OpenClawKit
- sdk: AppIntents.framework
@@ -247,8 +247,8 @@ targets:
- path: Sources/LiveActivity/OpenClawActivityAttributes.swift
- path: Sources/Fonts
buildPhase: resources
resources:
- path: Resources/Localizable.xcstrings
buildPhase: resources
dependencies:
- sdk: WidgetKit.framework
- sdk: ActivityKit.framework
@@ -290,8 +290,8 @@ targets:
- Info.plist
- path: Sources/Fonts
buildPhase: resources
resources:
- path: Resources/Localizable.xcstrings
buildPhase: resources
dependencies:
- sdk: AppIntents.framework
- sdk: WatchConnectivity.framework
@@ -183,10 +183,15 @@ public struct OpenClawChatMessageContent: Codable, Hashable, Sendable {
}
public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
private struct OpenClawMetadata: Codable {
let idempotencyKey: String?
}
public var id: UUID = .init()
public let role: String
public let content: [OpenClawChatMessageContent]
public let timestamp: Double?
public let idempotencyKey: String?
public let toolCallId: String?
public let toolName: String?
public let usage: OpenClawChatUsage?
@@ -197,6 +202,8 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
case role
case content
case timestamp
case idempotencyKey
case openClaw = "__openclaw"
case toolCallId
case tool_call_id
case toolName
@@ -211,6 +218,7 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
role: String,
content: [OpenClawChatMessageContent],
timestamp: Double?,
idempotencyKey: String? = nil,
toolCallId: String? = nil,
toolName: String? = nil,
usage: OpenClawChatUsage? = nil,
@@ -221,6 +229,7 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
self.role = role
self.content = content
self.timestamp = timestamp
self.idempotencyKey = idempotencyKey
self.toolCallId = toolCallId
self.toolName = toolName
self.usage = usage
@@ -232,6 +241,9 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
let container = try decoder.container(keyedBy: CodingKeys.self)
let decodedRole = try container.decode(String.self, forKey: .role)
let decodedTimestamp = try container.decodeIfPresent(Double.self, forKey: .timestamp)
let decodedOpenClaw = try container.decodeIfPresent(OpenClawMetadata.self, forKey: .openClaw)
let decodedIdempotencyKey = try decodedOpenClaw?.idempotencyKey ??
container.decodeIfPresent(String.self, forKey: .idempotencyKey)
let decodedToolCallId =
try container.decodeIfPresent(String.self, forKey: .toolCallId) ??
container.decodeIfPresent(String.self, forKey: .tool_call_id)
@@ -244,6 +256,7 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
self.role = decodedRole
self.timestamp = decodedTimestamp
self.idempotencyKey = decodedIdempotencyKey
self.toolCallId = decodedToolCallId
self.toolName = decodedToolName
self.usage = decodedUsage
@@ -315,6 +328,7 @@ public struct OpenClawChatMessage: Codable, Hashable, Identifiable, Sendable {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.role, forKey: .role)
try container.encodeIfPresent(self.timestamp, forKey: .timestamp)
try container.encodeIfPresent(self.idempotencyKey, forKey: .idempotencyKey)
try container.encodeIfPresent(self.toolCallId, forKey: .toolCallId)
try container.encodeIfPresent(self.toolName, forKey: .toolName)
try container.encodeIfPresent(self.usage, forKey: .usage)
@@ -44,6 +44,18 @@ public struct OpenClawChatView: View {
case clean
}
public struct StarterPrompt: Hashable, Identifiable, Sendable {
public let id: String
public let title: String
public let prompt: String
public init(id: String, title: String, prompt: String) {
self.id = id
self.title = title
self.prompt = prompt
}
}
@State private var viewModel: OpenClawChatViewModel
@Environment(\.scenePhase) private var scenePhase
@State private var scrollerBottomID = UUID()
@@ -69,6 +81,7 @@ public struct OpenClawChatView: View {
private let isComposerEnabled: Bool
private let messagePlaceholder: String?
private let emptyAssistantIntro: String?
private let emptyAssistantPrompts: [StarterPrompt]
private let talkControl: OpenClawChatTalkControl?
private enum ScrollFollowTarget: Equatable {
@@ -118,6 +131,7 @@ public struct OpenClawChatView: View {
isComposerEnabled: Bool = true,
messagePlaceholder: String? = nil,
emptyAssistantIntro: String? = nil,
emptyAssistantPrompts: [StarterPrompt] = [],
talkControl: OpenClawChatTalkControl? = nil)
{
_viewModel = State(initialValue: viewModel)
@@ -135,6 +149,7 @@ public struct OpenClawChatView: View {
self.isComposerEnabled = isComposerEnabled
self.messagePlaceholder = messagePlaceholder
self.emptyAssistantIntro = emptyAssistantIntro
self.emptyAssistantPrompts = emptyAssistantPrompts
self.talkControl = talkControl
}
@@ -299,7 +314,13 @@ public struct OpenClawChatView: View {
@ViewBuilder
private var messageListRows: some View {
if let introText = visibleEmptyAssistantIntro {
ChatAssistantIntroCard(text: introText)
ChatAssistantIntroCard(
text: introText,
prompts: self.emptyAssistantPrompts,
onPrompt: { prompt in
self.viewModel.input = prompt.prompt
self.viewModel.send()
})
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -692,6 +713,7 @@ public struct OpenClawChatView: View {
role: last.role,
content: content,
timestamp: last.timestamp,
idempotencyKey: last.idempotencyKey,
toolCallId: last.toolCallId,
toolName: last.toolName,
usage: last.usage,
@@ -805,22 +827,49 @@ public struct OpenClawChatView: View {
private struct ChatAssistantIntroCard: View {
let text: String
let prompts: [OpenClawChatView.StarterPrompt]
let onPrompt: (OpenClawChatView.StarterPrompt) -> Void
var body: some View {
// Rendered as a grey assistant bubble so the greeting reads like the
// agent's first message, matching the in-conversation bubble style.
Text(self.text)
.font(OpenClawChatTypography.body)
.foregroundStyle(OpenClawChatTheme.assistantText)
.multilineTextAlignment(.leading)
.padding(.vertical, 10)
.padding(.horizontal, 14)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(OpenClawChatTheme.assistantBubble))
.frame(maxWidth: 320, alignment: .leading)
.padding(.top, 8)
.frame(maxWidth: .infinity, alignment: .leading)
VStack(alignment: .leading, spacing: 10) {
// Rendered as a grey assistant bubble so the greeting reads like the
// agent's first message, matching the in-conversation bubble style.
Text(self.text)
.font(OpenClawChatTypography.body)
.foregroundStyle(OpenClawChatTheme.assistantText)
.multilineTextAlignment(.leading)
.padding(.vertical, 10)
.padding(.horizontal, 14)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(OpenClawChatTheme.assistantBubble))
ForEach(self.prompts) { prompt in
Button {
self.onPrompt(prompt)
} label: {
HStack(spacing: 8) {
Text(prompt.title)
.font(OpenClawChatTypography.body(size: 15, weight: .semibold, relativeTo: .callout))
.multilineTextAlignment(.leading)
Spacer(minLength: 8)
Image(systemName: "arrow.up.right")
.font(OpenClawChatTypography.captionSemiBold)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(OpenClawChatTheme.subtleCard))
}
.buttonStyle(.plain)
.accessibilityIdentifier("chat-starter-\(prompt.id)")
}
}
.frame(maxWidth: 340, alignment: .leading)
.padding(.top, 8)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@@ -133,6 +133,7 @@ public final class OpenClawChatViewModel {
}
private struct LatestUserTurn {
var idempotencyKey: String?
var refreshKey: String?
var occurrence: Int
var timestamp: Double?
@@ -459,6 +460,7 @@ public final class OpenClawChatViewModel {
}
self.replaceMessages(nextMessages)
self.prunePendingLocalUserEchoMessageIDs()
self.clearProvisionalFinalMarkersAdoptedByHistory(incoming)
self.pruneProvisionalFinalMessages()
self.pruneRunMessageScopes()
self.sessionId = payload.sessionId
@@ -610,6 +612,7 @@ public final class OpenClawChatViewModel {
role: message.role,
content: sanitizedContent,
timestamp: message.timestamp,
idempotencyKey: message.idempotencyKey,
toolCallId: message.toolCallId,
toolName: message.toolName,
usage: message.usage,
@@ -640,6 +643,12 @@ public final class OpenClawChatViewModel {
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !role.isEmpty else { return nil }
// The gateway persists this key with the canonical user row. Prefer it
// so a server timestamp change cannot replace the optimistic row's ID.
if let idempotencyKey = Self.normalizedIdempotencyKey(message.idempotencyKey) {
return [role, "idempotency", idempotencyKey].joined(separator: "|")
}
let timestamp: String = {
guard let value = message.timestamp, value.isFinite else { return "" }
return String(format: "%.3f", value)
@@ -672,8 +681,8 @@ public final class OpenClawChatViewModel {
guard role == "assistant" else { return nil }
// chat.final and session.message can serialize the same final row with
// different timestamps/content ids; the run user-turn scope owns safety
// for repeated same-text replies across turns.
// different timestamps/content ids. Reconciliation prefers the durable
// gateway run key, with user-turn scope as a legacy fallback.
let contentFingerprint = Self.finalMessageContentFingerprint(for: message)
let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
@@ -688,6 +697,28 @@ public final class OpenClawChatViewModel {
return trimmed.isEmpty ? nil : trimmed
}
private static func normalizedIdempotencyKey(_ key: String?) -> String? {
let trimmed = key?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private static func adoptingCanonicalMessage(
_ incoming: OpenClawChatMessage,
over existing: OpenClawChatMessage) -> OpenClawChatMessage
{
OpenClawChatMessage(
id: existing.id,
role: incoming.role,
content: incoming.content,
timestamp: incoming.timestamp ?? existing.timestamp,
idempotencyKey: incoming.idempotencyKey,
toolCallId: incoming.toolCallId,
toolName: incoming.toolName,
usage: incoming.usage,
stopReason: incoming.stopReason,
errorMessage: incoming.errorMessage)
}
private func currentRunMessageScope() -> RunMessageScope {
RunMessageScope(
session: self.currentSessionSnapshot(),
@@ -709,6 +740,9 @@ public final class OpenClawChatViewModel {
case (nil, nil):
return true
case let (lhs?, rhs?):
if lhs.idempotencyKey != nil || rhs.idempotencyKey != nil {
return lhs.idempotencyKey == rhs.idempotencyKey
}
if let lhsKey = lhs.refreshKey, let rhsKey = rhs.refreshKey {
return lhsKey == rhsKey && lhs.occurrence == rhs.occurrence
}
@@ -727,7 +761,14 @@ public final class OpenClawChatViewModel {
-> [OpenClawChatMessage].Index
{
guard let latestUserTurn else { return messages.startIndex }
if let refreshKey = latestUserTurn.refreshKey {
if let idempotencyKey = latestUserTurn.idempotencyKey,
let index = messages.lastIndex(where: { message in
message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user" &&
Self.normalizedIdempotencyKey(message.idempotencyKey) == idempotencyKey
})
{
return messages.index(after: index)
} else if let refreshKey = latestUserTurn.refreshKey {
var occurrence = 0
for index in messages.indices {
guard self.userRefreshIdentityKey(for: messages[index]) == refreshKey else { continue }
@@ -750,16 +791,39 @@ public final class OpenClawChatViewModel {
}).map { messages.index(after: $0) } ?? messages.startIndex
}
private static func messageRange(
after latestUserTurn: LatestUserTurn?,
in messages: [OpenClawChatMessage]) -> Range<[OpenClawChatMessage].Index>
{
let start = self.indexAfterLatestUserTurn(latestUserTurn, in: messages)
guard latestUserTurn != nil, start < messages.endIndex else {
return start..<messages.endIndex
}
// Without an exact assistant run key, any user row is a turn boundary.
// Steering and channel-originated turns are both metadata-free, so
// widening this range would make distinct replies indistinguishable.
let end = messages[start...].firstIndex { message in
message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user"
} ?? messages.endIndex
return start..<end
}
private func hasCanonicalFinalMessageMatching(
_ message: OpenClawChatMessage,
scope: RunMessageScope) -> Bool
{
guard let key = Self.finalMessageReconciliationKey(for: message) else { return false }
guard self.isCurrentSession(scope.session) else { return false }
let searchStart = Self.indexAfterLatestUserTurn(scope.latestUserTurn, in: self.messages)
guard searchStart < self.messages.endIndex else { return false }
if let runId = Self.normalizedIdempotencyKey(message.idempotencyKey) {
return self.messages.contains { existing in
self.provisionalFinalMessagesByID[existing.id] == nil &&
Self.normalizedIdempotencyKey(existing.idempotencyKey) == runId
}
}
let searchRange = Self.messageRange(after: scope.latestUserTurn, in: self.messages)
guard !searchRange.isEmpty else { return false }
return self.messages[searchStart...].contains { existing in
return self.messages[searchRange].contains { existing in
self.provisionalFinalMessagesByID[existing.id] == nil &&
Self.finalMessageReconciliationKey(for: existing) == key
}
@@ -781,6 +845,24 @@ public final class OpenClawChatViewModel {
}
}
private func clearProvisionalFinalMarkersAdoptedByHistory(_ incoming: [OpenClawChatMessage]) {
let canonicalRunIds = Set<String>(incoming.compactMap { message in
guard Self.isAssistantMessage(message) else { return nil }
return Self.normalizedIdempotencyKey(message.idempotencyKey)
})
guard !canonicalRunIds.isEmpty else { return }
self.provisionalFinalMessagesByID = self.provisionalFinalMessagesByID.filter { messageID, provisional in
guard let runId = provisional.runId,
canonicalRunIds.contains(runId),
let visible = self.messages.first(where: { $0.id == messageID }),
Self.normalizedIdempotencyKey(visible.idempotencyKey) == runId
else {
return true
}
return false
}
}
private func pruneRunMessageScopes() {
self.runMessageScopesByRunID = self.runMessageScopesByRunID.filter { entry in
self.isCurrentSession(entry.value.session)
@@ -793,12 +875,19 @@ public final class OpenClawChatViewModel {
}
}
private func adoptPendingLocalUserEcho(incoming: OpenClawChatMessage) -> Bool {
guard let incomingKey = Self.userRefreshIdentityKey(for: incoming) else { return false }
guard let matchIndex = messages.lastIndex(where: { existing in
self.pendingLocalUserEchoMessageIDsByRunID.values.contains(existing.id)
&& Self.userRefreshIdentityKey(for: existing) == incomingKey
}) else {
private func adoptCorrelatedUserMessage(incoming: OpenClawChatMessage) -> Bool {
guard incoming.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user" else {
return false
}
// The final event can clear pending bookkeeping before session.message
// arrives. The persisted key still identifies the exact user turn, so
// adopt the durable row without losing the optimistic row's UI identity.
guard let incomingKey = Self.normalizedIdempotencyKey(incoming.idempotencyKey) else { return false }
let matchIndex = self.messages.lastIndex { existing in
existing.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user" &&
Self.normalizedIdempotencyKey(existing.idempotencyKey) == incomingKey
}
guard let matchIndex else {
return false
}
@@ -807,31 +896,89 @@ public final class OpenClawChatViewModel {
$0.value != existing.id
}
var updated = self.messages
updated[matchIndex] = OpenClawChatMessage(
id: existing.id,
role: incoming.role,
content: incoming.content,
timestamp: incoming.timestamp ?? existing.timestamp,
toolCallId: incoming.toolCallId,
toolName: incoming.toolName,
usage: incoming.usage,
stopReason: incoming.stopReason,
errorMessage: incoming.errorMessage)
updated[matchIndex] = Self.adoptingCanonicalMessage(incoming, over: existing)
self.replaceMessages(Self.dedupeMessages(updated))
self.prunePendingLocalUserEchoMessageIDs()
return true
}
private func adoptProvisionalFinalMessage(incoming: OpenClawChatMessage) -> Bool {
guard let incomingKey = Self.finalMessageReconciliationKey(for: incoming) else { return false }
let canonicalScope = self.currentRunMessageScope()
let searchStart = Self.indexAfterLatestUserTurn(canonicalScope.latestUserTurn, in: self.messages)
guard searchStart < self.messages.endIndex else { return false }
private func rekeyLocalUserEcho(
messageID: UUID?,
runId: String) -> (pendingMessageID: UUID?, scope: RunMessageScope)?
{
guard let messageID, let matchIndex = self.messages.firstIndex(where: { $0.id == messageID }) else {
return nil
}
let existing = self.messages[matchIndex]
let remoteKey = "\(runId):user"
let canonical = self.messages.last { message in
message.id != messageID &&
message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user" &&
Self.normalizedIdempotencyKey(message.idempotencyKey) == remoteKey
}
var updated = self.messages
updated[matchIndex] = if let canonical {
Self.adoptingCanonicalMessage(canonical, over: existing)
} else {
OpenClawChatMessage(
id: existing.id,
role: existing.role,
content: existing.content,
timestamp: existing.timestamp,
idempotencyKey: remoteKey,
toolCallId: existing.toolCallId,
toolName: existing.toolName,
usage: existing.usage,
stopReason: existing.stopReason,
errorMessage: existing.errorMessage)
}
self.replaceMessages(Self.dedupeMessages(updated))
guard let survivingIndex = self.messages.firstIndex(where: { message in
message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user" &&
Self.normalizedIdempotencyKey(message.idempotencyKey) == remoteKey
}) else {
return nil
}
let survivingMessage = self.messages[survivingIndex]
let scope = RunMessageScope(
session: self.currentSessionSnapshot(),
latestUserTurn: Self.userTurn(at: survivingIndex, in: self.messages))
let pendingMessageID = survivingMessage.id == messageID ? messageID : nil
return (pendingMessageID, scope)
}
guard let matchIndex = messages[searchStart...].lastIndex(where: { existing in
private func rescopeProvisionalFinalMessages(runId: String, scope: RunMessageScope) {
let matchingMessageIDs = self.provisionalFinalMessagesByID.compactMap { messageID, provisional in
provisional.runId == runId ? messageID : nil
}
for messageID in matchingMessageIDs {
self.provisionalFinalMessagesByID[messageID]?.scope = scope
}
}
private func hasRecordedFinalMessage(runId: String) -> Bool {
if self.provisionalFinalMessagesByID.values.contains(where: { $0.runId == runId }) {
return true
}
return self.messages.contains { message in
Self.isAssistantMessage(message) &&
Self.normalizedIdempotencyKey(message.idempotencyKey) == runId
}
}
private func adoptProvisionalFinalMessage(incoming: OpenClawChatMessage) -> Bool {
let incomingRunId = Self.normalizedIdempotencyKey(incoming.idempotencyKey)
let incomingKey = Self.finalMessageReconciliationKey(for: incoming)
guard incomingRunId != nil || incomingKey != nil else { return false }
let canonicalUserTurn = self.currentRunMessageScope().latestUserTurn
guard let matchIndex = messages.indices.last(where: { index in
let existing = self.messages[index]
guard let provisional = self.provisionalFinalMessagesByID[existing.id] else { return false }
if let incomingRunId {
return provisional.runId == incomingRunId
}
return provisional.reconciliationKey == incomingKey &&
Self.isSameUserTurnBoundary(provisional.scope.latestUserTurn, canonicalScope.latestUserTurn)
Self.isSameUserTurnBoundary(provisional.scope.latestUserTurn, canonicalUserTurn)
}) else {
return false
}
@@ -839,16 +986,11 @@ public final class OpenClawChatViewModel {
let existing = self.messages[matchIndex]
let provisional = self.provisionalFinalMessagesByID[existing.id]
var updated = self.messages
updated[matchIndex] = OpenClawChatMessage(
id: existing.id,
role: incoming.role,
content: incoming.content,
timestamp: incoming.timestamp ?? existing.timestamp,
toolCallId: incoming.toolCallId,
toolName: incoming.toolName,
usage: incoming.usage,
stopReason: incoming.stopReason,
errorMessage: incoming.errorMessage)
updated.remove(at: matchIndex)
// The durable session.message arrives at its transcript position. A
// steering row may have been delivered after chat.final, so append the
// adopted row here while retaining the provisional UUID.
updated.append(Self.adoptingCanonicalMessage(incoming, over: existing))
self.provisionalFinalMessagesByID.removeValue(forKey: existing.id)
if let runId = provisional?.runId {
self.runMessageScopesByRunID.removeValue(forKey: runId)
@@ -865,36 +1007,27 @@ public final class OpenClawChatViewModel {
{
guard !previous.isEmpty, !incoming.isEmpty else { return incoming }
var idsByKey: [String: [UUID]] = [:]
var previousMessagesByKey: [String: [OpenClawChatMessage]] = [:]
for message in previous {
guard let key = Self.messageIdentityKey(for: message) else { continue }
idsByKey[key, default: []].append(message.id)
previousMessagesByKey[key, default: []].append(message)
}
return incoming.map { message in
guard let key = Self.messageIdentityKey(for: message),
var ids = idsByKey[key],
let reusedId = ids.first
var matches = previousMessagesByKey[key],
let existing = matches.first
else {
return message
}
ids.removeFirst()
if ids.isEmpty {
idsByKey.removeValue(forKey: key)
matches.removeFirst()
if matches.isEmpty {
previousMessagesByKey.removeValue(forKey: key)
} else {
idsByKey[key] = ids
previousMessagesByKey[key] = matches
}
guard reusedId != message.id else { return message }
return OpenClawChatMessage(
id: reusedId,
role: message.role,
content: message.content,
timestamp: message.timestamp,
toolCallId: message.toolCallId,
toolName: message.toolName,
usage: message.usage,
stopReason: message.stopReason,
errorMessage: message.errorMessage)
guard existing.id != message.id else { return message }
return Self.adoptingCanonicalMessage(message, over: existing)
}
}
@@ -913,6 +1046,9 @@ public final class OpenClawChatViewModel {
}
var reconciled = Self.reconcileMessageIDs(previous: previous, incoming: incoming)
let incomingIdempotencyKeys = Set(reconciled.compactMap { message in
Self.normalizedIdempotencyKey(message.idempotencyKey)
})
let incomingIdentityKeys = Set(reconciled.compactMap(Self.messageIdentityKey(for:)))
var remainingIncomingUserRefreshCounts = countKeys(
reconciled.compactMap(Self.userRefreshIdentityKey(for:)))
@@ -931,6 +1067,24 @@ public final class OpenClawChatViewModel {
remainingIncomingUserRefreshCounts[userKey] = remaining - 1
}
// Exact client correlation owns pending-send adoption. A metadata-free
// row is ambiguous across clients, so retain both rather than lose a turn.
let pendingLocalUsers = previous.filter { message in
guard message.role.lowercased() == "user", pendingLocalUserEchoIDs.contains(message.id) else {
return false
}
let matched = Self.normalizedIdempotencyKey(message.idempotencyKey)
.map(incomingIdempotencyKeys.contains) ?? false
guard matched else { return true }
if let userKey = Self.userRefreshIdentityKey(for: message),
let remaining = remainingIncomingUserRefreshCounts[userKey],
remaining > 0
{
remainingIncomingUserRefreshCounts[userKey] = remaining - 1
}
return false
}
let lastCanonicalPreviousIndex = previous.lastIndex { message in
guard let identityKey = Self.messageIdentityKey(for: message) else { return false }
return incomingIdentityKeys.contains(identityKey)
@@ -939,11 +1093,9 @@ public final class OpenClawChatViewModel {
previous[previous.index(after: index)...]
} ?? []
let pendingLocalUsers = previous.filter { message in
message.role.lowercased() == "user" && pendingLocalUserEchoIDs.contains(message.id)
}
let trailingLocalUsers = trailingLocalCandidates.filter { message in
guard message.role.lowercased() == "user" else { return false }
guard !pendingLocalUserEchoIDs.contains(message.id) else { return false }
guard let identityKey = Self.messageIdentityKey(for: message) else { return true }
guard !incomingIdentityKeys.contains(identityKey) else { return false }
guard let userKey = Self.userRefreshIdentityKey(for: message) else { return true }
@@ -995,6 +1147,9 @@ public final class OpenClawChatViewModel {
}
private static func dedupeKey(for message: OpenClawChatMessage) -> String? {
if let idempotencyKey = normalizedIdempotencyKey(message.idempotencyKey) {
return "\(message.role)|idempotency|\(idempotencyKey)"
}
guard let timestamp = message.timestamp else { return nil }
let text = message.content.compactMap(\.text).joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1311,7 +1466,8 @@ public final class OpenClawChatViewModel {
id: userMessageID,
role: "user",
content: userContent,
timestamp: userMessageTimestamp))
timestamp: userMessageTimestamp,
idempotencyKey: "\(runId):user"))
self.pendingLocalUserEchoMessageIDsByRunID[runId] = userMessageID
self.runMessageScopesByRunID[runId] = self.currentRunMessageScope()
@@ -1335,21 +1491,39 @@ public final class OpenClawChatViewModel {
self.logDiagnostic(
"chat.ui transport send accepted sessionKey=\(sessionKey) "
+ "localRunId=\(runId) remoteRunId=\(response.runId)")
var reusedRunAlreadyFinal = false
if response.runId != runId {
let pendingUserMessageID = self.pendingLocalUserEchoMessageIDsByRunID.removeValue(forKey: runId)
let runScope = self.runMessageScopesByRunID.removeValue(forKey: runId)
let localRunScope = self.runMessageScopesByRunID.removeValue(forKey: runId)
self.clearPendingRun(runId)
self.pendingRuns.insert(response.runId)
self.pendingLocalUserEchoMessageIDsByRunID[response.runId] = pendingUserMessageID
self.runMessageScopesByRunID[response.runId] = runScope
self.armPendingRunTimeout(runId: response.runId)
// The gateway can reuse an identical active run without writing
// this second turn. Move the optimistic row onto that durable
// identity, collapsing it if the canonical row is already here.
let rekeyedUserEcho = self.rekeyLocalUserEcho(
messageID: pendingUserMessageID,
runId: response.runId)
self.pendingLocalUserEchoMessageIDsByRunID[response.runId] = rekeyedUserEcho?.pendingMessageID
let remoteRunScope = rekeyedUserEcho?.scope ?? localRunScope ?? self.currentRunMessageScope()
self.runMessageScopesByRunID[response.runId] = remoteRunScope
self.rescopeProvisionalFinalMessages(runId: response.runId, scope: remoteRunScope)
reusedRunAlreadyFinal = self.hasRecordedFinalMessage(runId: response.runId)
if reusedRunAlreadyFinal {
self.clearPendingRun(response.runId)
self.pendingToolCallsById = [:]
self.updateStreamingAssistantText(nil)
} else {
self.armPendingRunTimeout(runId: response.runId)
}
}
if response.status == "ok" {
let historyContext = self.beginHistoryRequest(for: sessionSnapshot)
await self.refreshHistoryAfterRun(historyRequest: historyContext)
guard self.isCurrentSession(sessionSnapshot) else { return }
self.finishPendingRunAfterTerminalOkSendAck(response)
} else if !self.finishPendingRunIfTerminalSendAck(response) {
} else if !self.finishPendingRunIfTerminalSendAck(response),
!reusedRunAlreadyFinal
{
let historyContext = self.beginHistoryRequest(for: sessionSnapshot)
await self.refreshHistoryAfterRun(historyRequest: historyContext)
guard self.isCurrentSession(sessionSnapshot) else { return }
@@ -1999,9 +2173,10 @@ public final class OpenClawChatViewModel {
// just sent. performSend already appended an optimistic row carrying a
// local client timestamp, while the echo carries a server timestamp, so
// the timestamp-keyed identity/dedupe paths below never collapse them.
// Adopt the server record only onto a still-visible row created by this
// client's pending send; same-content user turns from other clients must append.
if self.adoptPendingLocalUserEcho(incoming: sanitized) {
// Adopt the server record onto the exactly correlated row even when the
// run's final event already cleared pending state. Same-content turns
// without this key remain distinct.
if self.adoptCorrelatedUserMessage(incoming: sanitized) {
return
}
if self.adoptProvisionalFinalMessage(incoming: sanitized) {
@@ -2133,6 +2308,7 @@ public final class OpenClawChatViewModel {
role: message.role,
content: message.content,
timestamp: Date().timeIntervalSince1970 * 1000,
idempotencyKey: message.idempotencyKey,
toolCallId: message.toolCallId,
toolName: message.toolName,
usage: message.usage,
@@ -2362,20 +2538,34 @@ public final class OpenClawChatViewModel {
guard let lastUserIndex = messages.lastIndex(where: { $0.role.lowercased() == "user" }) else {
return nil
}
guard let refreshKey = userRefreshIdentityKey(for: messages[lastUserIndex]) else {
return self.userTurn(at: lastUserIndex, in: messages)
}
private static func userTurn(
at userIndex: [OpenClawChatMessage].Index,
in messages: [OpenClawChatMessage]) -> LatestUserTurn?
{
guard messages.indices.contains(userIndex),
messages[userIndex].role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user"
else {
return nil
}
guard let refreshKey = userRefreshIdentityKey(for: messages[userIndex]) else {
return LatestUserTurn(
idempotencyKey: self.normalizedIdempotencyKey(messages[userIndex].idempotencyKey),
refreshKey: nil,
occurrence: 0,
timestamp: messages[lastUserIndex].timestamp)
timestamp: messages[userIndex].timestamp)
}
let occurrence = messages[...lastUserIndex].reduce(into: 0) { count, message in
let occurrence = messages[...userIndex].reduce(into: 0) { count, message in
guard self.userRefreshIdentityKey(for: message) == refreshKey else { return }
count += 1
}
return LatestUserTurn(
idempotencyKey: Self.normalizedIdempotencyKey(messages[userIndex].idempotencyKey),
refreshKey: refreshKey,
occurrence: occurrence,
timestamp: messages[lastUserIndex].timestamp)
timestamp: messages[userIndex].timestamp)
}
private static func hasAnsweredUser(
@@ -2383,6 +2573,17 @@ public final class OpenClawChatViewModel {
in messages: [OpenClawChatMessage])
-> Bool
{
// Hooks may transform persisted user content while preserving this key.
// Prefer the durable turn identity so a completed refresh rejects older history.
if let idempotencyKey = user.idempotencyKey {
guard let userIndex = messages.lastIndex(where: { message in
message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user" &&
self.normalizedIdempotencyKey(message.idempotencyKey) == idempotencyKey
}) else {
return false
}
return self.hasAssistantMessage(after: userIndex, in: messages)
}
guard let refreshKey = user.refreshKey else { return false }
var occurrence = 0
var latestMatchingUserIndex: [OpenClawChatMessage].Index?
@@ -2391,14 +2592,7 @@ public final class OpenClawChatViewModel {
occurrence += 1
latestMatchingUserIndex = index
guard occurrence == user.occurrence else { continue }
let nextIndex = messages.index(after: index)
guard nextIndex < messages.endIndex else { return false }
return messages[nextIndex...].contains { message in
guard message.role.lowercased() == "assistant" else { return false }
let text = message.content.compactMap(\.text).joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
return !text.isEmpty || message.errorMessage != nil
}
return self.hasAssistantMessage(after: index, in: messages)
}
guard let latestMatchingUserIndex,
messages.lastIndex(where: { $0.role.lowercased() == "user" }) == latestMatchingUserIndex
@@ -2411,7 +2605,14 @@ public final class OpenClawChatViewModel {
{
return false
}
let nextIndex = messages.index(after: latestMatchingUserIndex)
return self.hasAssistantMessage(after: latestMatchingUserIndex, in: messages)
}
private static func hasAssistantMessage(
after userIndex: [OpenClawChatMessage].Index,
in messages: [OpenClawChatMessage]) -> Bool
{
let nextIndex = messages.index(after: userIndex)
guard nextIndex < messages.endIndex else { return false }
return messages[nextIndex...].contains { message in
guard message.role.lowercased() == "assistant" else { return false }
File diff suppressed because it is too large Load Diff
+9
View File
@@ -42,6 +42,15 @@ const CATALOGS = [
"Sent to OpenClaw.",
],
"apps/ios/Sources/Design/SettingsChannelsDestination.swift": ["Logout"],
"apps/ios/Sources/Design/ChatProTab.swift": [
"Check OpenClaw status",
"Help me start a realtime voice session from this phone.",
"Help me start voice chat",
"Show me which phone controls and device capabilities are available right now.",
"Summarize the current OpenClaw status and tell me what needs attention.",
"What can I control here?",
"What would you like to work on?",
],
"apps/ios/Sources/Gateway/GatewayProblemView.swift": ["Done"],
"apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift": [
"Close",
+3
View File
@@ -76,6 +76,8 @@ const APPLE_EXTENSIONS = new Set([".swift", ".plist"]);
const NATIVE_FORMAT_RE = /%(?:\d+\$)?[@a-z]/giu;
const APPLE_UI_MULTILINE_CALLS =
/(?:Text|Label|Button|TextField|SecureField|Picker|Section|LabeledContent|Toggle|Menu|ShareLink|Link|TextEditor|ProgressView|Gauge|DisclosureGroup|ControlGroup|DatePicker|Stepper)\s*\(\s*"""([\s\S]*?)"""/gu;
const APPLE_LOCALIZED_STRING_CALLS =
/\b(?:String\s*\(\s*localized:|LocalizedStringResource\s*\()\s*"((?:\\.|[^"\\])*)"/gu;
const APPLE_CALL_START = /\b([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*/gu;
const APPLE_MODIFIER_CALLS =
/\.(?:navigationTitle|accessibilityLabel|accessibilityHint|help|alert|confirmationDialog)\s*\(\s*"((?:\\.|[^"\\])*)"/gu;
@@ -626,6 +628,7 @@ function extractCandidates(
surface === "apple"
? [
[APPLE_UI_MULTILINE_CALLS, "ui-call-multiline"],
[APPLE_LOCALIZED_STRING_CALLS, "ui-localized-call"],
[APPLE_MODIFIER_CALLS, "ui-modifier"],
[APPLE_MODIFIER_MULTILINE_CALLS, "ui-modifier-multiline"],
...CONDITIONAL_BRANCHES.map((pattern) => [pattern, "conditional-branch"] as const),
+23
View File
@@ -74,6 +74,29 @@ describe("native app i18n inventory", () => {
expect(entries.some((entry) => entry.source === "Searching…")).toBe(true);
expect(entries.some((entry) => entry.source === "Run now")).toBe(true);
expect(entries.some((entry) => entry.source === "Loading chat")).toBe(true);
expect(entries.some((entry) => entry.source === "What would you like to work on?")).toBe(true);
expect(entries.some((entry) => entry.source === "Check OpenClaw status")).toBe(true);
expect(entries.some((entry) => entry.source === "What can I control here?")).toBe(true);
expect(entries.some((entry) => entry.source === "Help me start voice chat")).toBe(true);
expect(
entries.some(
(entry) =>
entry.source ===
"Summarize the current OpenClaw status and tell me what needs attention.",
),
).toBe(true);
expect(
entries.some(
(entry) =>
entry.source ===
"Show me which phone controls and device capabilities are available right now.",
),
).toBe(true);
expect(
entries.some(
(entry) => entry.source === "Help me start a realtime voice session from this phone.",
),
).toBe(true);
expect(entries.some((entry) => entry.source === "DIARY")).toBe(true);
expect(entries.some((entry) => entry.source === "ask OpenClaw $prompt")).toBe(true);
expect(entries.some((entry) => entry.source === "OpenClaw is paused")).toBe(true);