mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
+166
-78
@@ -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",
|
||||
|
||||
@@ -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 d’OpenClaw"
|
||||
}
|
||||
},
|
||||
"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 l’appareil 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 d’OpenClaw 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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user