diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 645d511746c5..7b8165f3740c 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -32083,7 +32083,7 @@ }, { "kind": "ui-named-argument", - "line": 458, + "line": 462, "path": "apps/macos/Sources/OpenClaw/DashboardManager.swift", "source": "Dashboard unavailable", "surface": "apple", @@ -32091,7 +32091,7 @@ }, { "kind": "ui-named-argument", - "line": 460, + "line": 464, "path": "apps/macos/Sources/OpenClaw/DashboardManager.swift", "source": "Check Settings → Connection or use Debug → Reset Remote Tunnel, then try again.", "surface": "apple", @@ -32099,7 +32099,7 @@ }, { "kind": "ui-named-argument", - "line": 640, + "line": 644, "path": "apps/macos/Sources/OpenClaw/DashboardManager.swift", "source": "Could Not Switch Gateway", "surface": "apple", @@ -32107,7 +32107,7 @@ }, { "kind": "ui-named-argument", - "line": 668, + "line": 672, "path": "apps/macos/Sources/OpenClaw/DashboardManager.swift", "source": "Could Not Open Gateway Window", "surface": "apple", @@ -32115,7 +32115,7 @@ }, { "kind": "conditional-branch", - "line": 775, + "line": 779, "path": "apps/macos/Sources/OpenClaw/DashboardManager.swift", "source": "\\(base)-\\(UUID().uuidString)", "surface": "apple", @@ -32123,7 +32123,7 @@ }, { "kind": "ui-named-argument", - "line": 1129, + "line": 1133, "path": "apps/macos/Sources/OpenClaw/DashboardManager.swift", "source": "Could Not Set Primary Gateway", "surface": "apple", @@ -34947,7 +34947,7 @@ }, { "kind": "conditional-branch", - "line": 749, + "line": 718, "path": "apps/macos/Sources/OpenClaw/Onboarding.swift", "source": "Finish", "surface": "apple", @@ -34955,7 +34955,7 @@ }, { "kind": "conditional-branch", - "line": 749, + "line": 718, "path": "apps/macos/Sources/OpenClaw/Onboarding.swift", "source": "Next", "surface": "apple", @@ -35123,7 +35123,15 @@ }, { "kind": "ui-call", - "line": 335, + "line": 329, + "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", + "source": "Choose a different AI…", + "surface": "apple", + "id": "native.apple.c90ec3f0f60d21bc" + }, + { + "kind": "ui-call", + "line": 343, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "No usable AI access found on this Gateway", "surface": "apple", @@ -35131,7 +35139,7 @@ }, { "kind": "conditional-branch", - "line": 338, + "line": 346, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Connect a provider below with an API key or token, then check again.", "surface": "apple", @@ -35139,7 +35147,7 @@ }, { "kind": "conditional-branch", - "line": 339, + "line": 347, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Install one of these tools, then check again. You can also connect a provider below.", "surface": "apple", @@ -35147,7 +35155,7 @@ }, { "kind": "ui-call", - "line": 344, + "line": 352, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Recommended installs", "surface": "apple", @@ -35155,7 +35163,7 @@ }, { "kind": "ui-call", - "line": 367, + "line": 375, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Detected, but not auto-tested", "surface": "apple", @@ -35163,7 +35171,7 @@ }, { "kind": "ui-call", - "line": 375, + "line": 383, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "\\(candidate.label) — \\(candidate.detail)", "surface": "apple", @@ -35171,7 +35179,7 @@ }, { "kind": "ui-named-argument", - "line": 494, + "line": 502, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "No key-based providers are available", "surface": "apple", @@ -35179,7 +35187,7 @@ }, { "kind": "ui-named-argument", - "line": 495, + "line": 503, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Enable or install a text-inference provider plugin on this Gateway, then check again.", "surface": "apple", @@ -35187,7 +35195,7 @@ }, { "kind": "ui-named-argument", - "line": 497, + "line": 505, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Check again", "surface": "apple", @@ -35195,7 +35203,7 @@ }, { "kind": "ui-call", - "line": 511, + "line": 519, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Set up a local model", "surface": "apple", @@ -35203,7 +35211,7 @@ }, { "kind": "ui-call", - "line": 513, + "line": 521, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Connect a local model service, or prepare a model on this Gateway.", "surface": "apple", @@ -35211,7 +35219,7 @@ }, { "kind": "ui-localized-call", - "line": 537, + "line": 545, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Connect / Set up", "surface": "apple", @@ -35219,7 +35227,7 @@ }, { "kind": "ui-call", - "line": 559, + "line": 567, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Sign in with a provider", "surface": "apple", @@ -35227,7 +35235,7 @@ }, { "kind": "ui-call-concatenated", - "line": 561, + "line": 569, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Use an existing subscription or provider account. OpenClaw opens the provider’s own sign-in flow, then verifies it with a real reply.", "surface": "apple", @@ -35235,7 +35243,7 @@ }, { "kind": "ui-call", - "line": 576, + "line": 584, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "More sign-in options", "surface": "apple", @@ -35243,7 +35251,7 @@ }, { "kind": "ui-call", - "line": 606, + "line": 614, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "API Keys", "surface": "apple", @@ -35251,7 +35259,7 @@ }, { "kind": "conditional-branch", - "line": 646, + "line": 654, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Pair", "surface": "apple", @@ -35259,7 +35267,7 @@ }, { "kind": "conditional-branch", - "line": 646, + "line": 654, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Sign in", "surface": "apple", @@ -35267,7 +35275,7 @@ }, { "kind": "conditional-branch", - "line": 663, + "line": 671, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "OpenClaw will detect and verify the prepared model before using it.", "surface": "apple", @@ -35275,7 +35283,7 @@ }, { "kind": "conditional-branch", - "line": 664, + "line": 672, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Credentials stay on this Gateway and are saved only after the live test succeeds.", "surface": "apple", @@ -35283,7 +35291,7 @@ }, { "kind": "ui-call", - "line": 693, + "line": 701, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Open sign-in page…", "surface": "apple", @@ -35291,7 +35299,7 @@ }, { "kind": "conditional-branch", - "line": 701, + "line": 709, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Starting local model setup…", "surface": "apple", @@ -35299,7 +35307,7 @@ }, { "kind": "conditional-branch", - "line": 702, + "line": 710, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Starting secure sign-in…", "surface": "apple", @@ -35307,7 +35315,7 @@ }, { "kind": "conditional-branch", - "line": 709, + "line": 717, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Model setup didn’t complete", "surface": "apple", @@ -35315,7 +35323,7 @@ }, { "kind": "conditional-branch", - "line": 710, + "line": 718, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Sign-in didn’t complete", "surface": "apple", @@ -35323,7 +35331,7 @@ }, { "kind": "ui-call", - "line": 720, + "line": 728, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Cancel", "surface": "apple", @@ -35331,7 +35339,7 @@ }, { "kind": "ui-call", - "line": 743, + "line": 751, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Finish in your browser", "surface": "apple", @@ -35339,7 +35347,7 @@ }, { "kind": "ui-call", - "line": 759, + "line": 767, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Copy", "surface": "apple", @@ -35347,7 +35355,7 @@ }, { "kind": "ui-call", - "line": 770, + "line": 778, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Expires in \\(minutes) minutes", "surface": "apple", @@ -35355,7 +35363,7 @@ }, { "kind": "ui-call", - "line": 777, + "line": 785, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Open sign-in page", "surface": "apple", @@ -35363,7 +35371,7 @@ }, { "kind": "ui-call", - "line": 805, + "line": 813, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Option", "surface": "apple", @@ -35371,7 +35379,7 @@ }, { "kind": "ui-call", - "line": 811, + "line": 819, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Confirm", "surface": "apple", @@ -35379,7 +35387,7 @@ }, { "kind": "ui-localized-call", - "line": 820, + "line": 828, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "I've signed in", "surface": "apple", @@ -35387,7 +35395,7 @@ }, { "kind": "conditional-branch", - "line": 822, + "line": 830, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Continue", "surface": "apple", @@ -35395,7 +35403,7 @@ }, { "kind": "conditional-branch", - "line": 822, + "line": 830, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Submit", "surface": "apple", @@ -35403,7 +35411,7 @@ }, { "kind": "ui-call", - "line": 827, + "line": 835, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Connect with an API key or token", "surface": "apple", @@ -35411,7 +35419,7 @@ }, { "kind": "ui-call", - "line": 836, + "line": 844, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Provider", "surface": "apple", @@ -35419,7 +35427,7 @@ }, { "kind": "ui-call", - "line": 844, + "line": 852, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "API key or token", "surface": "apple", @@ -35427,7 +35435,7 @@ }, { "kind": "ui-call", - "line": 856, + "line": 864, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Connect", "surface": "apple", @@ -35435,7 +35443,7 @@ }, { "kind": "ui-named-argument", - "line": 869, + "line": 880, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "That key didn’t work", "surface": "apple", @@ -35443,7 +35451,7 @@ }, { "kind": "ui-call", - "line": 895, + "line": 906, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "OpenClaw — setup helper", "surface": "apple", @@ -35451,7 +35459,7 @@ }, { "kind": "ui-call", - "line": 898, + "line": 909, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Done", "surface": "apple", @@ -35459,7 +35467,7 @@ }, { "kind": "ui-call", - "line": 959, + "line": 970, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Open help…", "surface": "apple", @@ -35467,7 +35475,7 @@ }, { "kind": "conditional-branch", - "line": 998, + "line": 1009, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Hide details", "surface": "apple", @@ -35475,7 +35483,7 @@ }, { "kind": "conditional-branch", - "line": 998, + "line": 1009, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Show details", "surface": "apple", @@ -35483,44 +35491,12 @@ }, { "kind": "ui-call", - "line": 1021, + "line": 1032, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift", "source": "Copy error", "surface": "apple", "id": "native.apple.65fe24f910ca50cb" }, - { - "kind": "conditional-branch", - "line": 392, - "path": "apps/macos/Sources/OpenClaw/OnboardingMemoryImport.swift", - "source": "\\(incomplete) \\(Self.memoryNoun(incomplete)) could not be imported.", - "surface": "apple", - "id": "native.apple.7dee24e5bf088dd5" - }, - { - "kind": "conditional-branch", - "line": 499, - "path": "apps/macos/Sources/OpenClaw/OnboardingMemoryImport.swift", - "source": "The Gateway returned an inconsistent provider plan. Try planning again.", - "surface": "apple", - "id": "native.apple.84189c39ea5803c6" - }, - { - "kind": "conditional-branch", - "line": 502, - "path": "apps/macos/Sources/OpenClaw/OnboardingMemoryImport.swift", - "source": "The Gateway did not return a usable import plan. Try planning again.", - "surface": "apple", - "id": "native.apple.4c9e75d64cbc5cc2" - }, - { - "kind": "conditional-branch", - "line": 505, - "path": "apps/macos/Sources/OpenClaw/OnboardingMemoryImport.swift", - "source": "The Gateway could not plan \\(plan.summary.errors) \\(Self.memoryNoun(plan.summary.errors)). Try again.", - "surface": "apple", - "id": "native.apple.2070bf43a67680ee" - }, { "kind": "ui-localized-call", "line": 12, @@ -35611,7 +35587,7 @@ }, { "kind": "ui-call", - "line": 394, + "line": 362, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift", "source": "Back", "surface": "apple", @@ -35619,167 +35595,7 @@ }, { "kind": "ui-call", - "line": 6, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+MemoryImportPage.swift", - "source": "Bring your memories along", - "surface": "apple", - "id": "native.apple.7ca0977d5f73356d" - }, - { - "kind": "ui-call", - "line": 8, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+MemoryImportPage.swift", - "source": "OpenClaw can bring useful context from AI tools you already use into your new assistant.", - "surface": "apple", - "id": "native.apple.b57ee4f83cff23f3" - }, - { - "kind": "ui-named-argument", "line": 32, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+MemoryImportPage.swift", - "source": "Looking for memories…", - "surface": "apple", - "id": "native.apple.46cab71d748860b8" - }, - { - "kind": "ui-named-argument", - "line": 33, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+MemoryImportPage.swift", - "source": "Checking supported AI tools on this Mac.", - "surface": "apple", - "id": "native.apple.c9e5f1aebe85e201" - }, - { - "kind": "ui-named-argument", - "line": 38, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+MemoryImportPage.swift", - "source": "No memories to import", - "surface": "apple", - "id": "native.apple.77290f36f36f00e5" - }, - { - "kind": "ui-named-argument", - "line": 39, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+MemoryImportPage.swift", - "source": "You can import memories later from the dashboard.", - "surface": "apple", - "id": "native.apple.e5cca7b9f082ace9" - }, - { - "kind": "conditional-branch", - "line": 67, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+MemoryImportPage.swift", - "source": "Import memories", - "surface": "apple", - "id": "native.apple.963add2f12fd18cb" - }, - { - "kind": "conditional-branch", - "line": 67, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+MemoryImportPage.swift", - "source": "Importing memories…", - "surface": "apple", - "id": "native.apple.9db369e036a38a29" - }, - { - "kind": "ui-call", - "line": 75, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+MemoryImportPage.swift", - "source": "Refresh plan", - "surface": "apple", - "id": "native.apple.71b8e38a5e7040ef" - }, - { - "kind": "ui-call", - "line": 81, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+MemoryImportPage.swift", - "source": "Retry pending imports before refreshing the plan.", - "surface": "apple", - "id": "native.apple.e87bec72cb9c9dbf" - }, - { - "kind": "ui-call", - "line": 87, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+MemoryImportPage.swift", - "source": "You can skip this and import later from the dashboard’s Memory import page.", - "surface": "apple", - "id": "native.apple.f678888102fded6e" - }, - { - "kind": "ui-call", - "line": 109, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+MemoryImportPage.swift", - "source": "\\(provider.alreadyImportedCount) already imported", - "surface": "apple", - "id": "native.apple.36108528c6acc448" - }, - { - "kind": "ui-call", - "line": 114, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+MemoryImportPage.swift", - "source": "Imported \\(result.migrated) \\(self.memoryLabel(result.migrated)).", - "surface": "apple", - "id": "native.apple.9b3444de8c9b2acc" - }, - { - "kind": "ui-call", - "line": 154, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+MemoryImportPage.swift", - "source": "Couldn’t check for memories", - "surface": "apple", - "id": "native.apple.3252fea4dac8669a" - }, - { - "kind": "ui-call", - "line": 164, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+MemoryImportPage.swift", - "source": "Retry", - "surface": "apple", - "id": "native.apple.d371deb696de9e6b" - }, - { - "kind": "ui-call", - "line": 169, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+MemoryImportPage.swift", - "source": "You can do this later from the dashboard’s Memory import page.", - "surface": "apple", - "id": "native.apple.406ae454540725d0" - }, - { - "kind": "ui-call", - "line": 181, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+MemoryImportPage.swift", - "source": "Your memories are ready", - "surface": "apple", - "id": "native.apple.d1b5d99f67dae42e" - }, - { - "kind": "ui-call", - "line": 184, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+MemoryImportPage.swift", - "source": "Imported \\(imported) \\(self.memoryLabel(imported)) into OpenClaw.", - "surface": "apple", - "id": "native.apple.6dbe5b174271b62a" - }, - { - "kind": "ui-call", - "line": 190, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+MemoryImportPage.swift", - "source": "\\(result.migrated) imported", - "surface": "apple", - "id": "native.apple.d47b887f98e13e24" - }, - { - "kind": "ui-call", - "line": 196, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+MemoryImportPage.swift", - "source": "You can manage imported memories anytime from the dashboard.", - "surface": "apple", - "id": "native.apple.6ac3d513f0ec5da7" - }, - { - "kind": "ui-call", - "line": 34, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Welcome to OpenClaw", "surface": "apple", @@ -35787,7 +35603,7 @@ }, { "kind": "ui-call", - "line": 36, + "line": 34, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Your personal AI assistant, living on your own Mac.", "surface": "apple", @@ -35795,7 +35611,7 @@ }, { "kind": "ui-call-concatenated", - "line": 40, + "line": 38, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "It answers questions, works with your files and apps, and can chat with you on WhatsApp or Telegram. Setup takes about two minutes.", "surface": "apple", @@ -35803,7 +35619,7 @@ }, { "kind": "ui-named-argument", - "line": 51, + "line": 49, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Ask, create, and automate", "surface": "apple", @@ -35811,7 +35627,7 @@ }, { "kind": "ui-named-argument", - "line": 52, + "line": 50, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Give your assistant tasks and let it help across your Mac.", "surface": "apple", @@ -35819,7 +35635,7 @@ }, { "kind": "ui-named-argument", - "line": 55, + "line": 53, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Chat wherever you like", "surface": "apple", @@ -35827,7 +35643,7 @@ }, { "kind": "ui-named-argument", - "line": 56, + "line": 54, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "This app, WhatsApp, Telegram, Discord, Slack — your choice.", "surface": "apple", @@ -35835,7 +35651,7 @@ }, { "kind": "ui-named-argument", - "line": 59, + "line": 57, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Stay in control", "surface": "apple", @@ -35843,7 +35659,7 @@ }, { "kind": "ui-named-argument", - "line": 60, + "line": 58, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Everything runs where you decide, with permissions you grant.", "surface": "apple", @@ -35851,7 +35667,7 @@ }, { "kind": "ui-call-concatenated", - "line": 66, + "line": 64, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "OpenClaw can take actions using the permissions and services you enable. Review prompts and only connect tools you trust.", "surface": "apple", @@ -35859,7 +35675,7 @@ }, { "kind": "ui-call", - "line": 82, + "line": 80, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Where should your assistant live?", "surface": "apple", @@ -35867,7 +35683,7 @@ }, { "kind": "ui-call-concatenated", - "line": 84, + "line": 82, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Most people pick this Mac — OpenClaw installs everything and keeps it running in the background. You can change this anytime in Settings.", "surface": "apple", @@ -35875,7 +35691,7 @@ }, { "kind": "ui-named-argument", - "line": 96, + "line": 94, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "On this Mac", "surface": "apple", @@ -35883,7 +35699,7 @@ }, { "kind": "ui-named-argument", - "line": 106, + "line": 104, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "On another computer", "surface": "apple", @@ -35891,7 +35707,7 @@ }, { "kind": "ui-call", - "line": 131, + "line": 129, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Set up later", "surface": "apple", @@ -35899,7 +35715,7 @@ }, { "kind": "ui-modifier", - "line": 137, + "line": 135, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Skip Gateway setup for now; pick Local or Remote later in Settings → General.", "surface": "apple", @@ -35907,7 +35723,7 @@ }, { "kind": "ui-call", - "line": 141, + "line": 139, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "OK — OpenClaw won’t start anything yet. Pick Local or Remote later in Settings → General.", "surface": "apple", @@ -35915,7 +35731,7 @@ }, { "kind": "conditional-branch", - "line": 180, + "line": 178, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Existing gateway detected", "surface": "apple", @@ -35923,7 +35739,7 @@ }, { "kind": "conditional-branch", - "line": 181, + "line": 179, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Port \\(probe.port) already in use", "surface": "apple", @@ -35931,7 +35747,7 @@ }, { "kind": "conditional-branch", - "line": 182, + "line": 180, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": " (\\(probe.command) pid \\(probe.pid))", "surface": "apple", @@ -35939,7 +35755,7 @@ }, { "kind": "ui-localized-call", - "line": 193, + "line": 191, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "1 gateway found on your network — click to choose it.", "surface": "apple", @@ -35947,7 +35763,7 @@ }, { "kind": "ui-localized-call", - "line": 194, + "line": 192, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "\\(count) gateways found on your network — click to choose one.", "surface": "apple", @@ -35955,7 +35771,7 @@ }, { "kind": "ui-call", - "line": 208, + "line": 206, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "No gateways found on your network yet.", "surface": "apple", @@ -35963,7 +35779,7 @@ }, { "kind": "ui-call", - "line": 211, + "line": 209, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Look again", "surface": "apple", @@ -35971,7 +35787,7 @@ }, { "kind": "ui-modifier", - "line": 216, + "line": 214, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Retry discovery (Bonjour + Tailscale DNS-SD).", "surface": "apple", @@ -35979,7 +35795,7 @@ }, { "kind": "ui-call", - "line": 245, + "line": 243, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Advanced…", "surface": "apple", @@ -35987,7 +35803,7 @@ }, { "kind": "ui-call", - "line": 363, + "line": 361, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Remote connection", "surface": "apple", @@ -35995,7 +35811,7 @@ }, { "kind": "ui-call", - "line": 365, + "line": 363, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Verify OpenClaw can reach this gateway.", "surface": "apple", @@ -36003,7 +35819,7 @@ }, { "kind": "ui-call", - "line": 378, + "line": 376, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Check connection", "surface": "apple", @@ -36011,7 +35827,7 @@ }, { "kind": "conditional-branch", - "line": 420, + "line": 418, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Advanced options", "surface": "apple", @@ -36019,7 +35835,7 @@ }, { "kind": "conditional-branch", - "line": 420, + "line": 418, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Hide advanced options", "surface": "apple", @@ -36027,7 +35843,7 @@ }, { "kind": "ui-call", - "line": 439, + "line": 437, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Gateway token", "surface": "apple", @@ -36035,7 +35851,7 @@ }, { "kind": "ui-call", - "line": 442, + "line": 440, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Paste the token from your gateway", "surface": "apple", @@ -36043,7 +35859,7 @@ }, { "kind": "ui-call", - "line": 449, + "line": 447, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Only needed when the gateway requires token auth.", "surface": "apple", @@ -36051,7 +35867,7 @@ }, { "kind": "ui-call-concatenated", - "line": 458, + "line": 456, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.", "surface": "apple", @@ -36059,7 +35875,7 @@ }, { "kind": "ui-call", - "line": 476, + "line": 474, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Transport", "surface": "apple", @@ -36067,7 +35883,7 @@ }, { "kind": "ui-call", - "line": 477, + "line": 475, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "SSH tunnel", "surface": "apple", @@ -36075,7 +35891,7 @@ }, { "kind": "ui-call", - "line": 478, + "line": 476, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Direct (ws/wss)", "surface": "apple", @@ -36083,7 +35899,7 @@ }, { "kind": "ui-call", - "line": 486, + "line": 484, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Gateway URL", "surface": "apple", @@ -36091,7 +35907,7 @@ }, { "kind": "ui-call", - "line": 489, + "line": 487, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "wss://gateway.example.ts.net", "surface": "apple", @@ -36099,7 +35915,7 @@ }, { "kind": "ui-call", - "line": 496, + "line": 494, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "SSH target", "surface": "apple", @@ -36107,7 +35923,7 @@ }, { "kind": "ui-call", - "line": 516, + "line": 514, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Identity file", "surface": "apple", @@ -36115,7 +35931,7 @@ }, { "kind": "ui-call", - "line": 519, + "line": 517, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "/Users/you/.ssh/id_ed25519", "surface": "apple", @@ -36123,7 +35939,7 @@ }, { "kind": "ui-call", - "line": 524, + "line": 522, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Project root", "surface": "apple", @@ -36131,7 +35947,7 @@ }, { "kind": "ui-call", - "line": 527, + "line": 525, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "/home/you/Projects/openclaw", "surface": "apple", @@ -36139,7 +35955,7 @@ }, { "kind": "ui-call", - "line": 532, + "line": 530, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "CLI path", "surface": "apple", @@ -36147,7 +35963,7 @@ }, { "kind": "ui-call", - "line": 535, + "line": 533, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "/Applications/OpenClaw.app/.../openclaw", "surface": "apple", @@ -36155,7 +35971,7 @@ }, { "kind": "conditional-branch", - "line": 546, + "line": 544, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert.", "surface": "apple", @@ -36163,7 +35979,7 @@ }, { "kind": "conditional-branch", - "line": 547, + "line": 545, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Tip: keep Tailscale enabled so your gateway stays reachable.", "surface": "apple", @@ -36171,7 +35987,7 @@ }, { "kind": "ui-call", - "line": 561, + "line": 559, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Checking remote gateway…", "surface": "apple", @@ -36179,7 +35995,7 @@ }, { "kind": "conditional-branch", - "line": 693, + "line": 691, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": " · ssh \\(parsed.port)", "surface": "apple", @@ -36187,7 +36003,7 @@ }, { "kind": "ui-call", - "line": 762, + "line": 760, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Grant permissions", "surface": "apple", @@ -36195,7 +36011,7 @@ }, { "kind": "ui-call-concatenated", - "line": 769, + "line": 767, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "These macOS permissions let OpenClaw automate apps and capture context on this Mac. Status updates automatically.", "surface": "apple", @@ -36203,7 +36019,7 @@ }, { "kind": "ui-call", - "line": 804, + "line": 802, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Getting things ready", "surface": "apple", @@ -36211,7 +36027,7 @@ }, { "kind": "ui-named-argument", - "line": 815, + "line": 813, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Install OpenClaw", "surface": "apple", @@ -36219,7 +36035,7 @@ }, { "kind": "conditional-branch", - "line": 822, + "line": 820, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Prepare the Mac node", "surface": "apple", @@ -36227,7 +36043,7 @@ }, { "kind": "conditional-branch", - "line": 822, + "line": 820, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Start the background service", "surface": "apple", @@ -36235,7 +36051,7 @@ }, { "kind": "conditional-branch", - "line": 824, + "line": 822, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Runs inside the app and uses its macOS permissions.", "surface": "apple", @@ -36243,7 +36059,7 @@ }, { "kind": "conditional-branch", - "line": 825, + "line": 823, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Runs quietly and starts again after a restart.", "surface": "apple", @@ -36251,7 +36067,7 @@ }, { "kind": "ui-named-argument", - "line": 828, + "line": 826, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Ready for the next step", "surface": "apple", @@ -36259,7 +36075,7 @@ }, { "kind": "conditional-branch", - "line": 830, + "line": 828, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Once ready, this Mac connects to your selected Gateway.", "surface": "apple", @@ -36267,7 +36083,7 @@ }, { "kind": "conditional-branch", - "line": 831, + "line": 829, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Once the service answers, you’ll connect your AI.", "surface": "apple", @@ -36275,7 +36091,7 @@ }, { "kind": "ui-named-argument", - "line": 836, + "line": 834, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "The Gateway didn’t start", "surface": "apple", @@ -36283,7 +36099,7 @@ }, { "kind": "ui-named-argument", - "line": 839, + "line": 837, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Try again", "surface": "apple", @@ -36291,7 +36107,7 @@ }, { "kind": "ui-call", - "line": 930, + "line": 928, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "You’re all set!", "surface": "apple", @@ -36299,7 +36115,7 @@ }, { "kind": "ui-call", - "line": 933, + "line": 931, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Finish opens the chat — say hi to your new agent.", "surface": "apple", @@ -36307,7 +36123,7 @@ }, { "kind": "ui-named-argument", - "line": 941, + "line": 939, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Configure later", "surface": "apple", @@ -36315,7 +36131,7 @@ }, { "kind": "ui-named-argument", - "line": 942, + "line": 940, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Pick Local or Remote in Settings → General whenever you’re ready.", "surface": "apple", @@ -36323,7 +36139,7 @@ }, { "kind": "ui-named-argument", - "line": 949, + "line": 947, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Remote gateway checklist", "surface": "apple", @@ -36331,7 +36147,7 @@ }, { "kind": "ui-named-argument-multiline", - "line": 950, + "line": 948, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "On your gateway host: install/update the `openclaw` package and make sure credentials exist\n(typically `~/.openclaw/credentials/oauth.json`). Then connect again if needed.", "surface": "apple", @@ -36339,7 +36155,7 @@ }, { "kind": "ui-named-argument", - "line": 959, + "line": 957, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open the menu bar panel", "surface": "apple", @@ -36347,7 +36163,7 @@ }, { "kind": "ui-named-argument", - "line": 960, + "line": 958, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Click the OpenClaw menu bar icon for the compact chat panel and status.", "surface": "apple", @@ -36355,7 +36171,7 @@ }, { "kind": "ui-named-argument", - "line": 963, + "line": 961, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Connect Discord, Slack, Telegram, WhatsApp, …", "surface": "apple", @@ -36363,7 +36179,7 @@ }, { "kind": "ui-named-argument", - "line": 964, + "line": 962, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open Settings → Channels to link channels and monitor status.", "surface": "apple", @@ -36371,7 +36187,7 @@ }, { "kind": "ui-named-argument", - "line": 966, + "line": 964, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open Settings → Channels", "surface": "apple", @@ -36379,7 +36195,7 @@ }, { "kind": "ui-named-argument", - "line": 971, + "line": 969, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Try Voice Wake", "surface": "apple", @@ -36387,7 +36203,7 @@ }, { "kind": "ui-named-argument", - "line": 972, + "line": 970, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.", "surface": "apple", @@ -36395,7 +36211,7 @@ }, { "kind": "ui-named-argument", - "line": 975, + "line": 973, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Use the panel + Canvas", "surface": "apple", @@ -36403,7 +36219,7 @@ }, { "kind": "ui-named-argument-concatenated", - "line": 976, + "line": 974, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open the compact chat panel; the agent can show previews and richer visuals in Canvas.", "surface": "apple", @@ -36411,7 +36227,7 @@ }, { "kind": "ui-named-argument", - "line": 980, + "line": 978, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Give your agent more powers", "surface": "apple", @@ -36419,7 +36235,7 @@ }, { "kind": "ui-named-argument", - "line": 981, + "line": 979, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.", "surface": "apple", @@ -36427,7 +36243,7 @@ }, { "kind": "ui-named-argument", - "line": 983, + "line": 981, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open Settings → Skills", "surface": "apple", @@ -36435,7 +36251,7 @@ }, { "kind": "ui-call", - "line": 988, + "line": 986, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Launch at login", "surface": "apple", @@ -36443,7 +36259,7 @@ }, { "kind": "ui-call", - "line": 1015, + "line": 1013, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Skills included", "surface": "apple", @@ -36451,7 +36267,7 @@ }, { "kind": "ui-call", - "line": 1022, + "line": 1020, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Refresh", "surface": "apple", @@ -36459,7 +36275,7 @@ }, { "kind": "ui-call", - "line": 1031, + "line": 1029, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Couldn’t load skills from the Gateway.", "surface": "apple", @@ -36467,7 +36283,7 @@ }, { "kind": "ui-call-concatenated", - "line": 1034, + "line": 1032, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Make sure the Gateway is running and connected, then hit Refresh (or open Settings → Skills).", "surface": "apple", @@ -36475,7 +36291,7 @@ }, { "kind": "ui-call", - "line": 1040, + "line": 1038, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Details: \\(error)", "surface": "apple", @@ -36483,7 +36299,7 @@ }, { "kind": "ui-call", - "line": 1046, + "line": 1044, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "No skills reported yet.", "surface": "apple", diff --git a/apps/macos/Sources/OpenClaw/AppNavigationActions.swift b/apps/macos/Sources/OpenClaw/AppNavigationActions.swift index 69feed1e79ce..33284fd6b631 100644 --- a/apps/macos/Sources/OpenClaw/AppNavigationActions.swift +++ b/apps/macos/Sources/OpenClaw/AppNavigationActions.swift @@ -1,4 +1,5 @@ import AppKit +import OpenClawKit @MainActor enum AppNavigationActions { @@ -6,6 +7,17 @@ enum AppNavigationActions { DashboardManager.shared.presentDashboard() } + /// Post-AI-setup handoff: land in the dashboard's custodian onboarding, + /// which owns everything after working inference (memory import, channels, + /// app recommendations, hatch). + static func openDashboardOnboarding() { + Task { @MainActor in + await DashboardManager.shared.show( + atPath: DashboardRouteMap.custodianPagePath, + search: DashboardRouteMap.custodianOnboardingSearch) + } + } + static func openChat(sessionKey: String? = nil, agentID: String? = nil, draft: String? = nil) { NSApp.activate(ignoringOtherApps: true) Task { @MainActor in diff --git a/apps/macos/Sources/OpenClaw/DashboardManager.swift b/apps/macos/Sources/OpenClaw/DashboardManager.swift index d276d701c245..bc13ebaf4571 100644 --- a/apps/macos/Sources/OpenClaw/DashboardManager.swift +++ b/apps/macos/Sources/OpenClaw/DashboardManager.swift @@ -424,7 +424,7 @@ final class DashboardManager { Task { _ = try? await ControlChannel.shared.health(timeout: 3) } } - func show(atPath path: String) async { + func show(atPath path: String, search: String? = nil) async { self.navigationGeneration &+= 1 let generation = self.navigationGeneration do { @@ -433,9 +433,13 @@ final class DashboardManager { guard let controller, let fallbackURL = DashboardRouteMap.dashboardURL( byAppendingSameAppPath: path, + search: search, to: controller.dashboardBaseURL) else { return } - controller.dispatchNativeNavigation(DashboardNativeNavigation(path: path, fallbackURL: fallbackURL)) + controller.dispatchNativeNavigation(DashboardNativeNavigation( + path: path, + search: search, + fallbackURL: fallbackURL)) } catch { guard generation == self.navigationGeneration else { return } self.showFailure(error) diff --git a/apps/macos/Sources/OpenClaw/DashboardWindow.swift b/apps/macos/Sources/OpenClaw/DashboardWindow.swift index efca918a14ee..dbfd07bf4b40 100644 --- a/apps/macos/Sources/OpenClaw/DashboardWindow.swift +++ b/apps/macos/Sources/OpenClaw/DashboardWindow.swift @@ -63,6 +63,7 @@ enum DashboardNativeCommand: String { struct DashboardNativeNavigation: Equatable { let path: String + var search: String? let fallbackURL: URL } diff --git a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift index e3107105a1b0..fcbb0b8a7348 100644 --- a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift +++ b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift @@ -1221,11 +1221,12 @@ extension DashboardWindowController { private func evaluateNativeNavigation(_ navigation: DashboardNativeNavigation) { let generation = self.navigationGeneration let sourceURL = self.currentURL + let searchLiteral = navigation.search.map(Self.jsStringLiteral) ?? "undefined" let script = """ (() => !window.dispatchEvent(new CustomEvent('openclaw:native-navigate', { cancelable: true, - detail: {path: \(Self.jsStringLiteral(navigation.path))} + detail: {path: \(Self.jsStringLiteral(navigation.path)), search: \(searchLiteral)} })))() """ Task { @MainActor [weak self] in diff --git a/apps/macos/Sources/OpenClaw/Onboarding.swift b/apps/macos/Sources/OpenClaw/Onboarding.swift index d7ebe1576f91..d485f9454a03 100644 --- a/apps/macos/Sources/OpenClaw/Onboarding.swift +++ b/apps/macos/Sources/OpenClaw/Onboarding.swift @@ -625,7 +625,6 @@ struct OnboardingView: View { @State var onboardingSkillsModel = SkillsSettingsModel() @State var systemAgentState = OnboardingSystemAgentChatState() @State var aiSetup = OnboardingAISetupModel() - @State var memoryImport = OnboardingMemoryImportModel() @State var configuredGatewayProbe = OnboardingConfiguredGatewayProbe() @State var didLoadOnboardingSkills = false @State var localGatewayProbe: LocalGatewayProbe? @@ -635,7 +634,6 @@ struct OnboardingView: View { let systemAgentDefaults: UserDefaults let aiSetupRouteIdentityProvider: @MainActor () -> String? let gatewaySelectionPersister: @MainActor () -> Bool - let memoryImportGateway: GatewayConnection static let windowWidth: CGFloat = 630 static let windowHeight: CGFloat = 752 // ~+10% to fit full onboarding content @@ -645,7 +643,6 @@ struct OnboardingView: View { let connectionPageIndex = 1 let cliPageIndex = 2 let aiPageIndex = 3 - let memoryImportPageIndex = 4 let onboardingChatPageIndex = 8 let readyPageIndex = 9 @@ -672,45 +669,22 @@ struct OnboardingView: View { static func pageOrder( for mode: AppState.ConnectionMode, - requiresCLIInstall: Bool, - memoryImportEligible: Bool = false) -> [Int] + requiresCLIInstall: Bool) -> [Int] { switch mode { - case .remote: - // Remote mode skips local Gateway/workspace setup, but its Mac node - // still runs the matching CLI node-host runtime inside the app. - let setupPages = requiresCLIInstall ? [0, 1, 2, 3, 5] : [0, 1, 3, 5] - return setupPages + [9] + case .remote, .local: + // Native onboarding ends once inference works: install (when + // needed) plus AI setup. Everything after — memory import, + // permissions, channels, hatch — belongs to the dashboard's + // custodian onboarding, which Finish opens. + requiresCLIInstall ? [0, 1, 2, 3] : [0, 1, 3] case .unconfigured: - return [0, 1, 9] - case .local: - let memoryPages = memoryImportEligible ? [4] : [] - let setupPages = (requiresCLIInstall ? [0, 1, 2, 3] : [0, 1, 3]) + memoryPages + [5] - return setupPages + [9] + // "Set up later" has no gateway to hand off to; keep the native + // ready page so the flow still ends with a visible outcome. + [0, 1, 9] } } - static func shouldIncludeMemoryImportPage( - for mode: AppState.ConnectionMode, - modelEligible: Bool) -> Bool - { - mode == .local && modelEligible - } - - static func reconciledPageCursor( - currentPage: Int, - previousOrder: [Int], - newOrder: [Int]) -> Int - { - guard !newOrder.isEmpty else { return 0 } - guard !previousOrder.isEmpty else { return min(max(0, currentPage), newOrder.count - 1) } - let previousCursor = min(max(0, currentPage), previousOrder.count - 1) - let previousPage = previousOrder[previousCursor] - if let exact = newOrder.firstIndex(of: previousPage) { return exact } - if let next = newOrder.firstIndex(where: { $0 > previousPage }) { return next } - return newOrder.count - 1 - } - static func shouldActivateLocalGateway(afterCLIInstallFor mode: AppState.ConnectionMode) -> Bool { mode == .local } @@ -727,14 +701,9 @@ struct OnboardingView: View { } var pageOrder: [Int] { - let requiresCLIInstall = !self.cliInstalled - let includeMemoryImport = Self.shouldIncludeMemoryImportPage( + Self.pageOrder( for: self.state.connectionMode, - modelEligible: self.memoryImport.pageEligible) - return Self.pageOrder( - for: self.state.connectionMode, - requiresCLIInstall: requiresCLIInstall, - memoryImportEligible: includeMemoryImport) + requiresCLIInstall: !self.cliInstalled) } var pageCount: Int { @@ -783,7 +752,7 @@ struct OnboardingView: View { } var canAdvance: Bool { - !self.isCLIBlocking && !self.isAISetupBlocking && !self.memoryImport.isApplying + !self.isCLIBlocking && !self.isAISetupBlocking } struct LocalGatewayProbe: Equatable { @@ -815,7 +784,6 @@ struct OnboardingView: View { self.gatewaySelectionPersister = gatewaySelectionPersister ?? { state.syncGatewayConfigNow() } - self.memoryImportGateway = aiSetupGateway _defaultsToLocalGateway = State( initialValue: !state.onboardingSeen && state.connectionMode == .unconfigured) _gatewayDiscovery = State(initialValue: discoveryModel) diff --git a/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift b/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift index 69444270b674..ee148dfa6278 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift @@ -124,6 +124,10 @@ final class OnboardingAISetupModel { private let routeIdentityProvider: @MainActor () -> String? private var started = false private var attemptToken = UUID() + /// One-shot: the next detection pass lists choices without auto-activating, + /// so the connected-state "choose a different AI" path ends at a picker + /// instead of re-connecting the same auto candidate. + @ObservationIgnored private var suppressNextAutoActivation = false @ObservationIgnored private var pendingVerification: PendingVerification? @ObservationIgnored private var pendingActivationOwner: OnboardingSystemAgentResumeStore.ActivationOwner? @ObservationIgnored private var completedHandoff: CompletedHandoff? @@ -135,7 +139,9 @@ final class OnboardingAISetupModel { /// Only a just-completed provider flow may trust setupComplete without re-probing. @ObservationIgnored private var providerAuthReconciliationPending = false - private struct PersistedActivationState: Equatable { + /// Internal (not private): the persisted-transition helper lives in + /// OnboardingAISetupSupport.swift with the other pure statics. + struct PersistedActivationState: Equatable { let setupComplete: Bool let configuredModel: String? } @@ -231,6 +237,25 @@ final class OnboardingAISetupModel { scheduleDetection() } + /// Escape hatch from a successful auto-connect: re-detect and present every + /// candidate, provider, and API-key route without auto-activating, so the + /// user can replace the auto-chosen AI with one they pick themselves. + func chooseDifferentAI() { + guard self.beginChooseDifferentAI() else { return } + self.scheduleDetection() + } + + /// Split from `chooseDifferentAI` so tests can drive the detection await. + @discardableResult + func beginChooseDifferentAI() -> Bool { + guard self.connected else { return false } + self.resetForGatewayChange() + self.suppressNextAutoActivation = true + self.started = true + self.phase = .detecting + return true + } + func showConfiguredGatewayProbeUnavailable() { guard !self.ownsInferenceTransition || self.configuredGatewayProbeUnavailable || @@ -658,6 +683,7 @@ final class OnboardingAISetupModel { self.manualError = nil self.manualTesting = false self.showManualEntry = false + self.suppressNextAutoActivation = false if let authSessionToCancel, let authServerLease { Task { await self.gateway.cancelWizardSession(authSessionToCancel, on: authServerLease) @@ -775,6 +801,13 @@ extension OnboardingAISetupModel { self.statuses[candidate.kind] = .untried } self.phase = .ready + if self.suppressNextAutoActivation { + // "Choose a different AI" pass: list every route and let the + // user pick; auto-activating here would redo the undone choice. + self.suppressNextAutoActivation = false + self.showManualEntry = !self.manualProviders.isEmpty + return + } if let preparedChoiceID { // Detection kinds encode the provider-auth choice ID, while // PrepareOption.brandId owns the model-ref namespace. @@ -794,14 +827,15 @@ extension OnboardingAISetupModel { return } if let first = autoCandidateAfter(kind: nil) { - // Candidate found: connect without asking. Switching later - // stays one click away while the test runs server-side. + // Candidate found: connect without asking. The connected banner + // keeps "Choose a different AI" so this choice stays reversible. await self.activate(kind: first.kind, context: context) } else { self.showManualEntry = !self.manualProviders.isEmpty } } catch { guard self.isCurrentAttempt(context) else { return } + self.suppressNextAutoActivation = false self.phase = .ready self.detectError = Self.transportFailure(error.localizedDescription) self.showManualEntry = self.candidates.isEmpty @@ -841,16 +875,6 @@ extension OnboardingAISetupModel { "No Gateway is selected. Select a Gateway, then try again.") } - private static func activationTransitionWasPersisted( - expectedModel: String, - before: PersistedActivationState?, - after: PersistedActivationState?) -> Bool - { - guard let before, let after else { return false } - let wasAlreadyPersisted = before.setupComplete && before.configuredModel == expectedModel - return !wasAlreadyPersisted && after.setupComplete && after.configuredModel == expectedModel - } - /// Candidates the automatic ladder may try: skip definitively logged-out /// installs and anything already attempted. private func autoCandidateAfter(kind: String?) -> Candidate? { @@ -1123,16 +1147,6 @@ extension OnboardingAISetupModel { activationOwner: activationOwner) return self.connected } - - private static func remainingMilliseconds( - until deadline: ContinuousClock.Instant, - clock: ContinuousClock, - cappedAt capMs: Int) -> Int - { - let components = clock.now.duration(to: deadline).components - let milliseconds = components.seconds * 1000 + components.attoseconds / 1_000_000_000_000_000 - return max(0, min(capMs, Int(milliseconds))) - } } extension OnboardingAISetupModel { diff --git a/apps/macos/Sources/OpenClaw/OnboardingAISetupSupport.swift b/apps/macos/Sources/OpenClaw/OnboardingAISetupSupport.swift index 7aa2817fdb16..d100c1f58e5c 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingAISetupSupport.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingAISetupSupport.swift @@ -261,4 +261,24 @@ extension OnboardingAISetupModel { var connectedSetupCopyText: String { connectedSetupLines.joined(separator: "\n") } + + static func activationTransitionWasPersisted( + expectedModel: String, + before: PersistedActivationState?, + after: PersistedActivationState?) -> Bool + { + guard let before, let after else { return false } + let wasAlreadyPersisted = before.setupComplete && before.configuredModel == expectedModel + return !wasAlreadyPersisted && after.setupComplete && after.configuredModel == expectedModel + } + + static func remainingMilliseconds( + until deadline: ContinuousClock.Instant, + clock: ContinuousClock, + cappedAt capMs: Int) -> Int + { + let components = clock.now.duration(to: deadline).components + let milliseconds = components.seconds * 1000 + components.attoseconds / 1_000_000_000_000_000 + return max(0, min(capMs, Int(milliseconds))) + } } diff --git a/apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift b/apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift index 749cffe06f88..a42a1e289275 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift @@ -322,6 +322,14 @@ struct OnboardingAISetupView: View { .buttonStyle(.link) .font(.caption) } + + Button { + self.model.chooseDifferentAI() + } label: { + Label("Choose a different AI…", systemImage: "arrow.triangle.2.circlepath") + } + .buttonStyle(.link) + .font(.caption) } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) @@ -858,7 +866,10 @@ struct OnboardingAISetupView: View { } } .buttonStyle(.borderedProminent) - .disabled(self.model.manualTesting || + // isBusy, not just manualTesting: submitManualKey drops the tap + // while another test runs, so an enabled button would be a + // silent no-op. + .disabled(self.model.isBusy || self.model.manualKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } Text(self.manualProviderHelp) diff --git a/apps/macos/Sources/OpenClaw/OnboardingMemoryImport.swift b/apps/macos/Sources/OpenClaw/OnboardingMemoryImport.swift deleted file mode 100644 index 391063b28f3e..000000000000 --- a/apps/macos/Sources/OpenClaw/OnboardingMemoryImport.swift +++ /dev/null @@ -1,728 +0,0 @@ -import Foundation -import Observation -import OpenClawKit -import OpenClawProtocol - -@MainActor -@Observable -final class OnboardingMemoryImportModel { - struct Provider: Identifiable, Equatable { - let providerId: String - let label: String - let source: String? - let found: Bool - let plannedItemIds: [String] - let alreadyImportedCount: Int - let planFingerprint: String? - var selected: Bool - var inlineError: String? - var result: ProviderResult? - var requiresReplan: Bool - var appliedPlanFingerprint: String? - - var id: String { - self.providerId - } - - var plannedCount: Int { - self.plannedItemIds.count - } - - var isActionable: Bool { - self.found && !self.requiresReplan && self.plannedCount > 0 && - self.appliedPlanFingerprint != self.planFingerprint && - self.planFingerprint?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false - } - } - - struct ProviderResult: Identifiable, Equatable { - let providerId: String - let label: String - let migrated: Int - let skipped: Int - let conflicts: Int - let errors: Int - - var id: String { - self.providerId - } - } - - enum Phase: Equatable { - case idle - case planning - case offer([Provider]) - case empty - case failed(String) - case applying - case done([ProviderResult]) - } - - private(set) var phase: Phase = .idle - private(set) var autoAdvanceRequested = false - private(set) var applyingProviders: [Provider] = [] - - @ObservationIgnored private var operationToken = UUID() - @ObservationIgnored private var agentId: String? - @ObservationIgnored private var planningLease: GatewayConnection.ServerLease? - @ObservationIgnored private var applyIdempotencyKeys: [String: String] = [:] - @ObservationIgnored private var replanCarryover: [String: Provider] = [:] - @ObservationIgnored private var replanSourceLease: GatewayConnection.ServerLease? - @ObservationIgnored private var replanSourceAgentId: String? - @ObservationIgnored private var replanSourceWorkspace: String? - @ObservationIgnored private var planWorkspace: String? - @ObservationIgnored private var pageIsActive = false - private var failureDismissed = false - - var providers: [Provider] { - switch self.phase { - case let .offer(providers): providers - case .applying: self.applyingProviders - default: [] - } - } - - var results: [ProviderResult] { - if case let .done(results) = self.phase { return results } - return [] - } - - var hasOffer: Bool { - self.providers.contains { $0.found && $0.isActionable } - } - - var resolvedEmpty: Bool { - if case .empty = self.phase { return true } - return false - } - - var isApplying: Bool { - if case .applying = self.phase { return true } - return false - } - - var isFailed: Bool { - if case .failed = self.phase { return true } - return false - } - - /// Empty results stay in the pager only long enough for an active page to - /// hand off. A dismissed failure is likewise removed by the navigation owner. - var pageEligible: Bool { - switch self.phase { - case .empty: - self.autoAdvanceRequested - case .failed: - !self.failureDismissed - case .idle, .planning, .offer, .applying, .done: - true - } - } - - var hasSelectedProviders: Bool { - self.providers.contains { $0.selected && $0.isActionable } - } - - var hasReplanRequired: Bool { - self.providers.contains(where: \.requiresReplan) - } - - var canReplan: Bool { - self.hasReplanRequired && self.applyIdempotencyKeys.isEmpty - } - - var shouldStartAutomatically: Bool { - if case .idle = self.phase { return true } - return false - } - - func setPageActive(_ active: Bool) { - self.pageIsActive = active - } - - func setSelected(_ selected: Bool, providerId: String) { - guard case var .offer(providers) = self.phase, - let index = providers.firstIndex(where: { $0.providerId == providerId }), - providers[index].isActionable - else { return } - providers[index].selected = selected - providers[index].inlineError = nil - self.phase = .offer(providers) - } - - func consumeAutoAdvanceRequest() { - self.autoAdvanceRequested = false - self.pageIsActive = false - } - - func dismissFailure() { - guard self.isFailed else { return } - self.failureDismissed = true - self.pageIsActive = false - } - - func reset() { - self.operationToken = UUID() - self.agentId = nil - self.planningLease = nil - self.applyIdempotencyKeys = [:] - self.replanCarryover = [:] - self.replanSourceLease = nil - self.replanSourceAgentId = nil - self.replanSourceWorkspace = nil - self.planWorkspace = nil - self.phase = .idle - self.applyingProviders = [] - self.autoAdvanceRequested = false - self.pageIsActive = false - self.failureDismissed = false - } - - /// Resolve the default agent on the same physical Gateway connection used - /// for planning, so a reconnect cannot pair one server's agent with another. - func startPlanning(gateway: GatewayConnection) async { - guard let token = self.beginPlanning() else { return } - var lease: GatewayConnection.ServerLease? - do { - let (acquiredLease, preservedSourceRoute) = try await self.acquirePlanningLease(gateway: gateway) - lease = acquiredLease - let agentId = try await gateway.defaultAgentId(ifCurrentServerLease: acquiredLease) - guard self.isCurrent(token) else { return } - guard await gateway.isCurrentServerLease(acquiredLease) else { - self.finishStaleLease(token: token) - return - } - guard self.isCurrent(token) else { return } - await self.discardReplanCarryoverUnlessSameTarget( - agentId: agentId, - preservedSourceRoute: preservedSourceRoute) - guard self.isCurrent(token) else { return } - self.planningLease = acquiredLease - await self.performPlanning( - gateway: gateway, - agentId: agentId, - lease: acquiredLease, - token: token) - } catch { - if let lease, await !(gateway.isCurrentServerLease(lease)) { - self.finishStaleLease(token: token) - return - } - self.finishPlanningFailure(error.localizedDescription, token: token) - } - } - - func startPlanning(gateway: GatewayConnection, agentId: String) async { - guard let token = self.beginPlanning() else { return } - var lease: GatewayConnection.ServerLease? - do { - let (acquiredLease, preservedSourceRoute) = try await self.acquirePlanningLease(gateway: gateway) - lease = acquiredLease - guard self.isCurrent(token) else { return } - guard await gateway.isCurrentServerLease(acquiredLease) else { - self.finishStaleLease(token: token) - return - } - guard self.isCurrent(token) else { return } - await self.discardReplanCarryoverUnlessSameTarget( - agentId: agentId, - preservedSourceRoute: preservedSourceRoute) - guard self.isCurrent(token) else { return } - self.planningLease = acquiredLease - await self.performPlanning( - gateway: gateway, - agentId: agentId, - lease: acquiredLease, - token: token) - } catch { - if let lease, await !(gateway.isCurrentServerLease(lease)) { - self.finishStaleLease(token: token) - return - } - self.finishPlanningFailure(error.localizedDescription, token: token) - } - } - - func importSelected(gateway: GatewayConnection) async { - guard case let .offer(offeredProviders) = self.phase, - let agentId = self.agentId, - let lease = self.planningLease - else { return } - let selectedIds = Set(offeredProviders.compactMap { provider in - provider.selected && provider.isActionable - ? provider.providerId - : nil - }) - guard !selectedIds.isEmpty else { return } - - let token = UUID() - self.operationToken = token - self.applyingProviders = offeredProviders - self.phase = .applying - - guard await gateway.isCurrentServerLease(lease) else { - self.finishStaleLease(token: token) - return - } - guard self.isCurrent(token) else { return } - - var providers = self.applyingProviders - for index in providers.indices where selectedIds.contains(providers[index].providerId) { - let outcome = await self.applyOne( - at: index, - providers: &providers, - gateway: gateway, - agentId: agentId, - lease: lease, - token: token) - guard outcome == .continueBatch else { return } - } - - guard self.isCurrent(token) else { return } - guard await gateway.isCurrentServerLease(lease) else { - self.finishStaleLease(token: token, applyingProviders: providers) - return - } - guard self.isCurrent(token) else { return } - let results = providers.compactMap(\.result) - self.applyingProviders = [] - if providers.contains(where: { - $0.inlineError != nil || (($0.result?.errors ?? 0) + ($0.result?.conflicts ?? 0)) > 0 - }) { - self.phase = .offer(providers) - } else { - self.planningLease = nil - self.applyIdempotencyKeys = [:] - self.phase = .done(results) - } - } - - private enum ApplyBatchOutcome { - case continueBatch - case abort - } - - /// Applies one provider's planned items; mutates its row in place. `.abort` - /// means the operation token or server lease went stale mid-flight. - private func applyOne( - at index: Int, - providers: inout [Provider], - gateway: GatewayConnection, - agentId: String, - lease: GatewayConnection.ServerLease, - token: UUID) async -> ApplyBatchOutcome - { - guard self.isCurrent(token) else { return .abort } - guard await gateway.isCurrentServerLease(lease) else { - self.finishStaleLease(token: token, applyingProviders: providers) - return .abort - } - guard self.isCurrent(token) else { return .abort } - guard let fingerprint = providers[index].planFingerprint else { - providers[index].inlineError = "The Gateway did not return a usable import plan. Try planning again." - return .continueBatch - } - let providerId = providers[index].providerId - let idempotencyKey = self.applyIdempotencyKeys[providerId] ?? UUID().uuidString - self.applyIdempotencyKeys[providerId] = idempotencyKey - do { - let data = try await gateway.request( - method: "migrations.memory.apply", - params: [ - "idempotencyKey": AnyCodable(idempotencyKey), - "agentId": AnyCodable(agentId), - "providerId": AnyCodable(providerId), - "planFingerprint": AnyCodable(fingerprint), - "itemIds": AnyCodable(providers[index].plannedItemIds), - "overwrite": AnyCodable(false), - ], - timeoutMs: 120_000, - ifCurrentServerLease: lease) - guard self.isCurrent(token) else { return .abort } - guard await gateway.isCurrentServerLease(lease) else { - self.finishStaleLease(token: token, applyingProviders: providers) - return .abort - } - guard self.isCurrent(token) else { return .abort } - let result = try JSONDecoder().decode(MigrationsMemoryApplyResult.self, from: data) - guard result.providerid == providerId else { - throw OnboardingMemoryImportError.unexpectedApplyProvider - } - self.applyIdempotencyKeys.removeValue(forKey: providerId) - Self.recordApplyResult( - &providers[index], - summary: result.summary, - fingerprint: fingerprint) - } catch { - guard self.isCurrent(token) else { return .abort } - guard await gateway.isCurrentServerLease(lease) else { - self.finishStaleLease(token: token, applyingProviders: providers) - return .abort - } - guard self.isCurrent(token) else { return .abort } - // A Gateway rejection is definitive. Transport and decode - // failures are ambiguous, so a retry must reuse the same key. - if error is GatewayResponseError { - self.applyIdempotencyKeys.removeValue(forKey: providerId) - providers[index].selected = false - providers[index].requiresReplan = true - } - providers[index].inlineError = error.localizedDescription - } - return .continueBatch - } - - /// Conflicts mean selected items were skipped after planning (a target - /// appeared mid-apply); they need a replan, not "done". - private static func recordApplyResult( - _ provider: inout Provider, - summary: MemoryMigrationSummary, - fingerprint: String) - { - provider.selected = false - provider.result = self.mergeResult( - provider.result, - providerId: provider.providerId, - label: provider.label, - summary: summary) - let incomplete = summary.errors + summary.conflicts - provider.requiresReplan = incomplete > 0 - provider.appliedPlanFingerprint = incomplete == 0 ? fingerprint : nil - provider.inlineError = incomplete > 0 - ? "\(incomplete) \(Self.memoryNoun(incomplete)) could not be imported." - : nil - } - - private func beginPlanning() -> UUID? { - switch self.phase { - case .idle: - self.discardReplanCarryover() - case .failed: - break - case let .offer(providers): - guard self.canReplan else { return nil } - self.replanCarryover = Dictionary(uniqueKeysWithValues: providers.map { ($0.providerId, $0) }) - self.replanSourceLease = self.planningLease - self.replanSourceAgentId = self.agentId - self.replanSourceWorkspace = self.planWorkspace - case .planning, .empty, .applying, .done: - return nil - } - let token = UUID() - self.operationToken = token - self.agentId = nil - self.planningLease = nil - self.planWorkspace = nil - if self.replanCarryover.isEmpty { - self.applyIdempotencyKeys = [:] - } - self.phase = .planning - self.applyingProviders = [] - self.autoAdvanceRequested = false - self.failureDismissed = false - return token - } - - private func performPlanning( - gateway: GatewayConnection, - agentId: String, - lease: GatewayConnection.ServerLease, - token: UUID) async - { - do { - let data = try await gateway.request( - method: "migrations.memory.plan", - params: [ - "agentId": AnyCodable(agentId), - "overwrite": AnyCodable(false), - ], - timeoutMs: 30000, - ifCurrentServerLease: lease) - guard self.isCurrent(token) else { return } - guard await gateway.isCurrentServerLease(lease) else { - self.finishStaleLease(token: token) - return - } - guard self.isCurrent(token) else { return } - let result = try JSONDecoder().decode(MigrationsMemoryPlanResult.self, from: data) - try Self.validatePlan(result, requestedAgentId: agentId) - if self.replanSourceWorkspace != nil, - self.replanSourceWorkspace != result.workspace - { - self.discardReplanCarryoverAndPendingKeys() - } - let providers = self.mergeReplanCarryover(into: result.providers.map(Self.provider(from:))) - self.agentId = agentId - self.planWorkspace = result.workspace - if let provider = providers.first(where: { - $0.requiresReplan && self.applyIdempotencyKeys[$0.providerId] != nil - }) { - self.finishPlanningFailure( - provider.inlineError ?? "The Gateway could not refresh a pending memory import. Try again.", - token: token) - } else if providers.contains(where: { $0.found && $0.isActionable }) { - self.planningLease = lease - self.discardReplanCarryover() - self.phase = .offer(providers) - } else if let provider = providers.first(where: { $0.inlineError != nil }), - let error = provider.inlineError - { - self.finishPlanningFailure("\(provider.label): \(error)", token: token) - } else if !providers.compactMap(\.result).isEmpty { - self.planningLease = nil - self.discardReplanCarryover() - self.phase = .done(providers.compactMap(\.result)) - } else { - self.planningLease = nil - self.discardReplanCarryover() - self.phase = .empty - self.autoAdvanceRequested = self.pageIsActive - } - } catch { - guard self.isCurrent(token) else { return } - guard await gateway.isCurrentServerLease(lease) else { - self.finishStaleLease(token: token) - return - } - guard self.isCurrent(token) else { return } - self.finishPlanningFailure(error.localizedDescription, token: token) - } - } - - private static func provider(from plan: MemoryMigrationProviderPlan) -> Provider { - let plannedItemIds = plan.items.compactMap { item in - item.status == .planned ? item.id : nil - } - let fingerprint = plan.planfingerprint?.trimmingCharacters(in: .whitespacesAndNewlines) - let usableFingerprint = fingerprint?.isEmpty == false ? fingerprint : nil - let inconsistentFoundError = !plan.found && !plannedItemIds.isEmpty - ? "The Gateway returned an inconsistent provider plan. Try planning again." - : nil - let missingFingerprintError = !plannedItemIds.isEmpty && usableFingerprint == nil - ? "The Gateway did not return a usable import plan. Try planning again." - : nil - let summaryError = plan.summary.errors > 0 - ? "The Gateway could not plan \(plan.summary.errors) \(Self.memoryNoun(plan.summary.errors)). Try again." - : nil - let planError = plan.error ?? inconsistentFoundError ?? missingFingerprintError ?? summaryError - return Provider( - providerId: plan.providerid, - label: plan.label, - source: plan.source, - found: plan.found, - plannedItemIds: plannedItemIds, - alreadyImportedCount: plan.items.count { $0.status == .conflict }, - planFingerprint: usableFingerprint, - selected: plan.found && !plannedItemIds.isEmpty && usableFingerprint != nil && planError == nil, - inlineError: planError, - result: nil, - requiresReplan: planError != nil, - appliedPlanFingerprint: nil) - } - - private func mergeReplanCarryover(into freshProviders: [Provider]) -> [Provider] { - guard !self.replanCarryover.isEmpty else { return freshProviders } - var merged = freshProviders.map { fresh -> Provider in - guard let previous = self.replanCarryover[fresh.providerId] else { return fresh } - let identityChanged = previous.planFingerprint != fresh.planFingerprint || - previous.plannedItemIds != fresh.plannedItemIds - if identityChanged, !fresh.requiresReplan { - self.applyIdempotencyKeys.removeValue(forKey: fresh.providerId) - } - var provider = fresh - provider.result = previous.result - provider.appliedPlanFingerprint = identityChanged ? nil : previous.appliedPlanFingerprint - if previous.result?.errors == 0 { - provider.selected = !provider.requiresReplan && provider.isActionable - if !provider.requiresReplan { - provider.inlineError = nil - } - } else if previous.requiresReplan { - if !provider.requiresReplan { - provider.inlineError = nil - } - } else { - provider.selected = previous.selected && provider.isActionable - provider.inlineError = identityChanged - ? fresh.inlineError - : (fresh.inlineError ?? previous.inlineError) - } - return provider - } - let mergedIds = Set(merged.map(\.providerId)) - for providerId in Array(self.applyIdempotencyKeys.keys) where !mergedIds.contains(providerId) { - self.applyIdempotencyKeys.removeValue(forKey: providerId) - } - merged.append(contentsOf: self.replanCarryover.values.compactMap { previous in - guard previous.result != nil, - !mergedIds.contains(previous.providerId) - else { return nil } - var completed = previous - completed.selected = false - completed.inlineError = nil - completed.requiresReplan = false - completed.appliedPlanFingerprint = completed.planFingerprint - return completed - }) - return merged - } - - private func acquirePlanningLease( - gateway: GatewayConnection) async throws -> (GatewayConnection.ServerLease, Bool) - { - if let sourceLease = self.replanSourceLease { - let lease = try await gateway.acquireServerLease( - ifSameRouteAs: sourceLease, - timeoutMs: 15000) - return (lease, true) - } - return try await (gateway.acquireServerLease(), false) - } - - private func discardReplanCarryoverUnlessSameTarget( - agentId: String, - preservedSourceRoute: Bool) async - { - guard !self.replanCarryover.isEmpty else { return } - guard self.replanSourceAgentId == agentId, - preservedSourceRoute - else { - self.discardReplanCarryoverAndPendingKeys() - return - } - } - - private func discardReplanCarryover() { - self.replanCarryover = [:] - self.replanSourceLease = nil - self.replanSourceAgentId = nil - self.replanSourceWorkspace = nil - } - - private func discardReplanCarryoverAndPendingKeys() { - self.discardReplanCarryover() - self.applyIdempotencyKeys = [:] - } - - private static func mergeResult( - _ previous: ProviderResult?, - providerId: String, - label: String, - summary: MemoryMigrationSummary) -> ProviderResult - { - ProviderResult( - providerId: providerId, - label: label, - migrated: (previous?.migrated ?? 0) + summary.migrated, - skipped: (previous?.skipped ?? 0) + summary.skipped, - conflicts: (previous?.conflicts ?? 0) + summary.conflicts, - errors: summary.errors) - } - - private static func validatePlan( - _ result: MigrationsMemoryPlanResult, - requestedAgentId: String) throws - { - guard result.agentid == requestedAgentId else { - throw OnboardingMemoryImportError.unexpectedPlanAgent - } - var providerIds = Set() - for provider in result.providers { - let providerId = provider.providerid.trimmingCharacters(in: .whitespacesAndNewlines) - guard !providerId.isEmpty, - providerId == provider.providerid, - providerIds.insert(providerId).inserted - else { - throw OnboardingMemoryImportError.invalidProviderIdentity - } - } - } - - private func finishPlanningFailure(_ message: String, token: UUID) { - guard self.isCurrent(token) else { return } - self.agentId = nil - self.planningLease = nil - self.planWorkspace = nil - if self.replanCarryover.isEmpty { - self.applyIdempotencyKeys = [:] - } - self.phase = .failed(message) - } - - private func finishStaleLease( - token: UUID, - applyingProviders currentProviders: [Provider]? = nil) - { - guard self.isCurrent(token) else { return } - if let currentProviders { - self.applyingProviders = currentProviders - } - if !self.applyingProviders.isEmpty { - self.replanCarryover = Dictionary(uniqueKeysWithValues: self.applyingProviders.map { - ($0.providerId, $0) - }) - self.replanSourceLease = self.planningLease - self.replanSourceAgentId = self.agentId - self.replanSourceWorkspace = self.planWorkspace - } else if self.replanCarryover.isEmpty { - self.discardReplanCarryover() - self.applyIdempotencyKeys = [:] - } else if let planningLease = self.planningLease { - self.replanSourceLease = planningLease - } - self.agentId = nil - self.planningLease = nil - self.planWorkspace = nil - self.applyingProviders = [] - self.phase = .failed("The Gateway reconnected while checking memories. Try again.") - } - - private static func memoryNoun(_ count: Int) -> String { - count == 1 ? "memory" : "memories" - } - - private func isCurrent(_ token: UUID) -> Bool { - self.operationToken == token && !Task.isCancelled - } -} - -private struct DefaultAgentIdResult: Decodable { - let defaultId: String -} - -extension GatewayConnection { - func defaultAgentId(ifCurrentServerLease lease: ServerLease) async throws -> String { - let data = try await self.request( - method: "agents.list", - params: [:], - timeoutMs: 15000, - ifCurrentServerLease: lease) - guard await self.isCurrentServerLease(lease) else { throw CancellationError() } - let result = try JSONDecoder().decode(DefaultAgentIdResult.self, from: data) - let id = result.defaultId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !id.isEmpty else { - throw OnboardingMemoryImportError.missingDefaultAgent - } - return id - } -} - -private enum OnboardingMemoryImportError: LocalizedError { - case missingDefaultAgent - case unexpectedPlanAgent - case invalidProviderIdentity - case unexpectedApplyProvider - - var errorDescription: String? { - switch self { - case .missingDefaultAgent: - "The Gateway did not report a default agent for memory import." - case .unexpectedPlanAgent: - "The Gateway returned a memory plan for a different agent. Try again." - case .invalidProviderIdentity: - "The Gateway returned an invalid memory provider plan. Try again." - case .unexpectedApplyProvider: - "The Gateway returned a result for a different memory provider. Try again." - } - } -} diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift index 39ccce4ff455..9dcbdc975d2f 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift @@ -86,13 +86,6 @@ extension OnboardingView { func handleNext() { // All callers (Next button, chat handoff) honor the same page gates. guard canAdvance else { return } - if self.activePageIndex == self.memoryImportPageIndex, - self.memoryImport.isFailed - { - self.memoryImport.dismissFailure() - self.updateMonitoring(for: self.activePageIndex) - return - } self.commitRecommendedConnectionIfNeeded(for: activePageIndex) if currentPage < pageCount - 1 { withAnimation { self.currentPage += 1 } @@ -114,18 +107,15 @@ extension OnboardingView { aiSetup.clearCompletedHandoffIfOwned() OnboardingController.markComplete() OnboardingController.shared.close() - // Land people in the real conversation, not on an empty desktop: the - // agent chat is the product, and it is verified working by now. - if state.connectionMode != .unconfigured { - AppNavigationActions.openChat(draft: agentDraft?.composerValue) + guard state.connectionMode != .unconfigured else { return } + // An explicit agent handoff from the helper chat carries a composer + // draft; land that in the chat it was written for. + if let agentDraft { + AppNavigationActions.openChat(draft: agentDraft.composerValue) + return } - } - - func advancePastEmptyMemoryImportIfNeeded() { - guard self.memoryImport.autoAdvanceRequested else { return } - withAnimation { - self.memoryImport.consumeAutoAdvanceRequest() - } - self.updateMonitoring(for: self.activePageIndex) + // Inference works; the dashboard's custodian onboarding owns the rest + // (memory import, channels, permissions guidance, hatch). + AppNavigationActions.openDashboardOnboarding() } } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift index c331347ed701..2e89e903142b 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift @@ -61,18 +61,6 @@ extension OnboardingView { guard installed else { return } self.updateMonitoring(for: self.activePageIndex) } - .onChange(of: aiSetup.connected) { _, connected in - guard connected else { return } - self.maybeStartMemoryImportPlanning() - } - .onChange(of: memoryImport.autoAdvanceRequested) { _, requested in - guard requested else { return } - self.advancePastEmptyMemoryImportIfNeeded() - } - .onChange(of: memoryImport.pageEligible) { wasEligible, isEligible in - guard wasEligible, !isEligible else { return } - self.reconcileCursorAfterMemoryImportRemoval() - } .onDisappear { self.onboardingDidDisappear() } @@ -105,7 +93,6 @@ extension OnboardingView { // Queued detection can otherwise proceed into a mutating activation // after the window or its selected route has gone away. aiSetup.resetForGatewayChange(clearPendingHandoff: false) - memoryImport.reset() systemAgentState.resetForGatewayChange() stopPermissionMonitoring() stopDiscovery() @@ -117,24 +104,6 @@ extension OnboardingView { return pageOrder[clamped] } - func reconcileCursorAfterMemoryImportRemoval() { - guard self.state.connectionMode == .local else { return } - let previousOrder = Self.pageOrder( - for: .local, - requiresCLIInstall: !self.cliInstalled, - memoryImportEligible: true) - let newOrder = Self.pageOrder( - for: .local, - requiresCLIInstall: !self.cliInstalled, - memoryImportEligible: false) - let target = Self.reconciledPageCursor( - currentPage: self.currentPage, - previousOrder: previousOrder, - newOrder: newOrder) - guard target != self.currentPage else { return } - withAnimation { self.currentPage = target } - } - func reconcilePageForModeChange(previousActivePageIndex: Int) { if let exact = pageOrder.firstIndex(of: previousActivePageIndex) { withAnimation { self.currentPage = exact } @@ -170,7 +139,6 @@ extension OnboardingView { // The UI attempt belongs to one route, but its durable activation lease // must survive A -> B -> A while the old Gateway can still be mutating. aiSetup.resetForGatewayChange(clearPendingHandoff: false) - memoryImport.reset() // OpenClaw sessions belong to one Gateway. Dismiss and replace the chat so // changing routes cannot send an old session ID to the new endpoint. systemAgentState.resetForGatewayChange() @@ -397,7 +365,7 @@ extension OnboardingView { .buttonStyle(.plain) .foregroundColor(.secondary) .opacity(0.8) - .disabled(self.installingCLI || self.aiSetup.isBusy || self.memoryImport.isApplying) + .disabled(self.installingCLI || self.aiSetup.isBusy) .transition(.opacity.combined(with: .scale(scale: 0.9))) } } @@ -407,8 +375,7 @@ extension OnboardingView { HStack(spacing: 8) { ForEach(0.. (connectionLockIndex ?? 0) diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+MemoryImportPage.swift b/apps/macos/Sources/OpenClaw/OnboardingView+MemoryImportPage.swift deleted file mode 100644 index e2bf49617fa2..000000000000 --- a/apps/macos/Sources/OpenClaw/OnboardingView+MemoryImportPage.swift +++ /dev/null @@ -1,219 +0,0 @@ -import SwiftUI - -extension OnboardingView { - func memoryImportPage(contentHeight: CGFloat) -> some View { - VStack(spacing: 12) { - Text("Bring your memories along") - .font(.largeTitle.weight(.semibold)) - Text("OpenClaw can bring useful context from AI tools you already use into your new assistant.") - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 540) - .fixedSize(horizontal: false, vertical: true) - - ScrollView { - self.memoryImportContent - .padding(.vertical, 4) - .padding(.trailing, 12) - } - .scrollIndicators(.automatic) - } - .padding(.horizontal, 28) - .padding(.top, 48) - .frame(width: self.pageWidth, height: contentHeight, alignment: .top) - } - - @ViewBuilder - private var memoryImportContent: some View { - switch self.memoryImport.phase { - case .idle, .planning: - self.memoryImportProgress( - title: "Looking for memories…", - detail: "Checking supported AI tools on this Mac.") - case .offer: - self.memoryImportOffer(applying: false) - case .empty: - self.memoryImportProgress( - title: "No memories to import", - detail: "You can import memories later from the dashboard.") - case let .failed(message): - self.memoryImportFailure(message: message) - case .applying: - self.memoryImportOffer(applying: true) - case let .done(results): - self.memoryImportResults(results) - } - } - - private func memoryImportOffer(applying: Bool) -> some View { - VStack(spacing: 14) { - self.onboardingCard(spacing: 0, padding: 0) { - ForEach(Array(self.memoryImport.providers.enumerated()), id: \.element.id) { index, provider in - if index > 0 { Divider() } - self.memoryImportProviderRow(provider) - .padding(14) - } - } - - Button { - Task { await self.memoryImport.importSelected(gateway: self.memoryImportGateway) } - } label: { - HStack(spacing: 8) { - if applying { - ProgressView() - .controlSize(.small) - } - Text(applying ? "Importing memories…" : "Import memories") - } - .frame(minWidth: 150) - } - .buttonStyle(.borderedProminent) - .disabled(applying || !self.memoryImport.hasSelectedProviders) - - if !applying, self.memoryImport.hasReplanRequired { - Button("Refresh plan") { - Task { await self.memoryImport.startPlanning(gateway: self.memoryImportGateway) } - } - .buttonStyle(.bordered) - .disabled(!self.memoryImport.canReplan) - if !self.memoryImport.canReplan { - Text("Retry pending imports before refreshing the plan.") - .font(.footnote) - .foregroundStyle(.secondary) - } - } - - Text("You can skip this and import later from the dashboard’s Memory import page.") - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: 520) - } - - private func memoryImportProviderRow(_ provider: OnboardingMemoryImportModel.Provider) -> some View { - Toggle(isOn: Binding( - get: { self.memoryImport.providers.first(where: { $0.id == provider.id })?.selected ?? false }, - set: { self.memoryImport.setSelected($0, providerId: provider.providerId) })) - { - VStack(alignment: .leading, spacing: 4) { - Text(provider.label) - .font(.headline) - Text( - "\(provider.plannedCount) \(self.memoryLabel(provider.plannedCount)) · " + - (provider.source ?? "local files")) - .font(.subheadline) - .foregroundStyle(.secondary) - if provider.alreadyImportedCount > 0 { - Text("\(provider.alreadyImportedCount) already imported") - .font(.footnote) - .foregroundStyle(.secondary) - } - if let result = provider.result { - Text("Imported \(result.migrated) \(self.memoryLabel(result.migrated)).") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.green) - } - if let error = provider.inlineError { - Text(error) - .font(.footnote) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) - } - } - } - .toggleStyle(.checkbox) - .disabled(self.memoryImport.isApplying || !provider.isActionable) - } - - private func memoryImportProgress(title: String, detail: String) -> some View { - self.onboardingCard { - HStack(spacing: 14) { - ProgressView() - .controlSize(.regular) - VStack(alignment: .leading, spacing: 3) { - Text(title).font(.headline) - Text(detail) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - } - .frame(maxWidth: 520) - } - - private func memoryImportFailure(message: String) -> some View { - VStack(spacing: 14) { - self.onboardingCard { - HStack(alignment: .top, spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.title2) - .foregroundStyle(.orange) - VStack(alignment: .leading, spacing: 5) { - Text("Couldn’t check for memories") - .font(.headline) - Text(message) - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - - Button("Retry") { - Task { await self.memoryImport.startPlanning(gateway: self.memoryImportGateway) } - } - .buttonStyle(.borderedProminent) - - Text("You can do this later from the dashboard’s Memory import page.") - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: 520) - } - - private func memoryImportResults(_ results: [OnboardingMemoryImportModel.ProviderResult]) -> some View { - let imported = results.reduce(0) { $0 + $1.migrated } - return VStack(spacing: 14) { - self.onboardingCard(spacing: 12) { - Label("Your memories are ready", systemImage: "checkmark.circle.fill") - .font(.headline) - .foregroundStyle(.green) - Text("Imported \(imported) \(self.memoryLabel(imported)) into OpenClaw.") - .font(.body) - ForEach(results) { result in - HStack { - Text(result.label) - Spacer() - Text("\(result.migrated) imported") - .foregroundStyle(.secondary) - } - .font(.subheadline) - } - } - Text("You can manage imported memories anytime from the dashboard.") - .font(.footnote) - .foregroundStyle(.secondary) - } - .frame(maxWidth: 520) - } - - private func memoryLabel(_ count: Int) -> String { - count == 1 ? "memory" : "memories" - } - - func updateMemoryImportMonitoring(for pageIndex: Int) { - self.memoryImport.setPageActive(pageIndex == self.memoryImportPageIndex) - self.maybeStartMemoryImportPlanning() - } - - func maybeStartMemoryImportPlanning() { - guard self.state.connectionMode == .local, - self.aiSetup.connected, - self.memoryImport.shouldStartAutomatically - else { return } - Task { await self.memoryImport.startPlanning(gateway: self.memoryImportGateway) } - } -} diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift index efb5e50fb7e3..0ff456798c8f 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift @@ -44,7 +44,6 @@ extension OnboardingView { self.updateDiscoveryMonitoring(for: pageIndex) self.maybeInstallCLI(for: pageIndex) self.maybeStartAISetup(for: pageIndex) - self.updateMemoryImportMonitoring(for: pageIndex) } func maybeInstallCLI(for pageIndex: Int) { diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 9d279da82bee..8febffc3556b 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -16,8 +16,6 @@ extension OnboardingView { self.cliPage() case 3: self.aiSetupPage(contentHeight: contentHeight) - case 4: - self.memoryImportPage(contentHeight: contentHeight) case 5: self.permissionsPage() case 9: diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift index c475998c3617..21594c073a5b 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift @@ -39,7 +39,6 @@ extension OnboardingView { _ = view.welcomePage() _ = view.connectionPage() _ = view.aiSetupPage(contentHeight: contentHeight) - _ = view.memoryImportPage(contentHeight: contentHeight) _ = view.permissionsPage() _ = view.cliPage() _ = view.readyPage() diff --git a/apps/macos/Sources/OpenClaw/OnboardingWidgets.swift b/apps/macos/Sources/OpenClaw/OnboardingWidgets.swift index 4139166e4526..aa5d354bc115 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingWidgets.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingWidgets.swift @@ -43,7 +43,6 @@ extension OnboardingView { case connection case cli case ai - case memory case permissions case chat case ready @@ -58,7 +57,6 @@ extension OnboardingView { var aiPhase: OnboardingAISetupModel.Phase = .idle var aiBusy = false var aiFailed = false - var memoryPhase: OnboardingMemoryImportModel.Phase = .idle var remoteProbeState: RemoteOnboardingProbeState = .idle var allPermissionsGranted = false } @@ -75,7 +73,6 @@ extension OnboardingView { aiPhase: self.aiSetup.phase, aiBusy: self.aiSetup.isBusy, aiFailed: Self.aiSetupLooksFailed(self.aiSetup), - memoryPhase: self.memoryImport.phase, remoteProbeState: self.remoteProbeState, allPermissionsGranted: Capability.importanceOrdered .allSatisfy { self.permissionMonitor.status[$0]?.isGranted == true })) @@ -90,7 +87,6 @@ extension OnboardingView { case self.connectionPageIndex: .connection case self.cliPageIndex: .cli case self.aiPageIndex: .ai - case self.memoryImportPageIndex: .memory case self.permissionsPageIndex: .permissions case self.onboardingChatPageIndex: .chat case self.readyPageIndex: .ready @@ -140,8 +136,6 @@ extension OnboardingView { } else { .curious } - case .memory: - self.memoryImportMood(for: snapshot.memoryPhase) case .permissions: snapshot.allPermissionsGranted ? .happy : .curious case .chat: @@ -154,20 +148,7 @@ extension OnboardingView { static func mascotAccessory(for page: MascotPage) -> OpenClawMascotAccessory { switch page { case .ready: .gradCap - case .welcome, .connection, .cli, .ai, .memory, .permissions, .chat: .none - } - } - - static func memoryImportMood(for phase: OnboardingMemoryImportModel.Phase) -> OpenClawMascotMood { - switch phase { - case .planning, .applying: - .thinking - case .failed: - .sad - case .done: - .happy - case .idle, .offer, .empty: - .curious + case .welcome, .connection, .cli, .ai, .permissions, .chat: .none } } } diff --git a/apps/macos/Sources/OpenClaw/TextSummarySupport.swift b/apps/macos/Sources/OpenClaw/TextSummarySupport.swift index a58caf8800f0..7be4af71e6cd 100644 --- a/apps/macos/Sources/OpenClaw/TextSummarySupport.swift +++ b/apps/macos/Sources/OpenClaw/TextSummarySupport.swift @@ -6,11 +6,32 @@ enum TextSummarySupport { .split(whereSeparator: \.isNewline) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } - guard let last = lines.last else { return nil } - let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + guard !lines.isEmpty else { return nil } + // Node fatal errors end with a bare "Node.js vX.Y.Z" banner. Surfacing + // that line hides the actual failure ("Error: Cannot find module …"), + // so drop the banner and prefer the last real error line above it. + var candidates = lines[...] + var droppedNodeBanner = false + while let last = candidates.last, Self.isNodeVersionBanner(last) { + candidates = candidates.dropLast() + droppedNodeBanner = true + } + var chosen = candidates.last ?? lines[lines.count - 1] + if droppedNodeBanner, let errorLine = candidates.suffix(40).last(where: self.isErrorLine) { + chosen = errorLine + } + let normalized = chosen.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) if normalized.count > maxLength { return String(normalized.prefix(maxLength - 1)) + "…" } return normalized } + + private static func isNodeVersionBanner(_ line: String) -> Bool { + line.range(of: #"^Node\.js v\d"#, options: .regularExpression) != nil + } + + private static func isErrorLine(_ line: String) -> Bool { + line.range(of: #"^\w*Error(\b|:)"#, options: .regularExpression) != nil + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift index dfccb186c4f9..385cbd7944ef 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift @@ -1059,6 +1059,75 @@ struct OnboardingAISetupTests { defaults: defaults) == .none) } + @Test func `choose a different AI relists routes without auto-activating`() async throws { + let suiteName = "OnboardingChooseDifferentAITests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let recorder = AISetupRequestRecorder() + let session = GatewayTestWebSocketSession(taskFactory: { + GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in + guard sendIndex > 0, let request = aiSetupRequest(from: message) else { return } + if respondToAISetupHealth(task: task, request: request) { + return + } + await recorder.record(message) + switch request.method { + case "openclaw.setup.detect": + // credentials:true would auto-activate; the choose-different + // pass must end at the picker even for actionable candidates. + task.emitReceiveSuccess(.data(actionableDetectedSetupResponse(id: request.id))) + case "openclaw.setup.activate": + task.emitReceiveSuccess(.data(verifiedSetupResponse(id: request.id))) + default: + break + } + }) + }) + let url = try #require(URL(string: "ws://example.invalid")) + let gateway = GatewayConnection( + configProvider: { (url: url, token: nil, password: nil) }, + sessionBox: WebSocketSessionBox(session: session)) + let model = OnboardingAISetupModel( + gateway: gateway, + defaults: defaults, + routeIdentityProvider: { "local" }) + + await model.detectAndAutoConnect() + #expect(model.connected) + + #expect(model.beginChooseDifferentAI()) + await model.detectAndAutoConnect() + + #expect(!model.connected) + #expect(!model.isBusy) + #expect(model.candidates.count == 1) + #expect(model.statuses["claude-cli"] == .untried) + #expect(model.showManualEntry) + // Exactly one activation: the initial auto-connect. The re-detect pass + // must not redo the choice the user just asked to change. + let snapshot = await recorder.snapshot() + #expect(snapshot.methods.filter { $0 == "openclaw.setup.activate" }.count == 1) + #expect(snapshot.methods.last == "openclaw.setup.detect") + } + + @Test func `choose a different AI requires a connected setup`() throws { + let suiteName = "OnboardingChooseDifferentAIGuardTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName) ?? .standard + defer { defaults.removePersistentDomain(forName: suiteName) } + let url = try #require(URL(string: "ws://example.invalid")) + let gateway = GatewayConnection( + configProvider: { (url: url, token: nil, password: nil) }, + sessionBox: WebSocketSessionBox(session: GatewayTestWebSocketSession(taskFactory: { + GatewayTestWebSocketTask(sendHook: { _, _, _ in }) + }))) + let model = OnboardingAISetupModel( + gateway: gateway, + defaults: defaults, + routeIdentityProvider: { "local" }) + + #expect(!model.beginChooseDifferentAI()) + } + @Test func `adopts pending activation stored under the retired crestodian key`() throws { let suiteName = "OnboardingRetiredKeyMigrationTests-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) @@ -1381,8 +1450,16 @@ struct OnboardingAISetupTests { setupOwnsInferenceTransition: false)) } - @Test func `configured model label stays pending until live verification`() async { - let model = OnboardingAISetupModel() + @Test func `configured model label stays pending until live verification`() async throws { + // Isolated defaults + fixed route: the default init reads the machine's + // real resume store, whose leftover activation leases fail this test on + // any Mac that completed onboarding. + let suiteName = "OnboardingConfiguredLabelTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let model = OnboardingAISetupModel( + defaults: defaults, + routeIdentityProvider: { "local" }) model.resumeConfiguredInference(modelRef: " openai/gpt-5.5 ") diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingMascotMoodTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingMascotMoodTests.swift index e6adb169a216..1164890a766b 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingMascotMoodTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingMascotMoodTests.swift @@ -47,12 +47,6 @@ struct OnboardingMascotMoodTests { #expect(self.mood(.init(page: .permissions, allPermissionsGranted: true)) == .happy) } - @Test func `memory import page follows import lifecycle`() { - #expect(self.mood(.init(page: .memory, memoryPhase: .planning)) == .thinking) - #expect(self.mood(.init(page: .memory, memoryPhase: .failed("offline"))) == .sad) - #expect(self.mood(.init(page: .memory, memoryPhase: .done([]))) == .happy) - } - @Test func `chat and ready pages`() { #expect(self.mood(.init(page: .chat)) == .attentive) #expect(self.mood(.init(page: .ready)) == .celebrating) diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingMemoryImportTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingMemoryImportTests.swift deleted file mode 100644 index fdcc7da82b78..000000000000 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingMemoryImportTests.swift +++ /dev/null @@ -1,967 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw -@testable import OpenClawKit - -private struct MemoryImportWireRequest: Sendable { - let id: String - let method: String - let agentId: String? - let providerId: String? - let planFingerprint: String? - let itemIds: [String] - let overwrite: Bool? - let idempotencyKey: String? -} - -private actor MemoryImportRequestRecorder { - private var requests: [MemoryImportWireRequest] = [] - - func record(_ request: MemoryImportWireRequest) { - self.requests.append(request) - } - - func snapshot() -> [MemoryImportWireRequest] { - self.requests - } -} - -private actor MemoryImportRequestGate { - private var started = false - private var released = false - private var startWaiters: [CheckedContinuation] = [] - private var releaseWaiters: [CheckedContinuation] = [] - - func wait() async { - self.started = true - self.startWaiters.forEach { $0.resume() } - self.startWaiters.removeAll() - guard !self.released else { return } - await withCheckedContinuation { self.releaseWaiters.append($0) } - } - - func waitUntilStarted() async { - guard !self.started else { return } - await withCheckedContinuation { self.startWaiters.append($0) } - } - - func release() { - self.released = true - self.releaseWaiters.forEach { $0.resume() } - self.releaseWaiters.removeAll() - } -} - -private actor MemoryImportApplyCounter { - private var counts: [String: Int] = [:] - - func next(for providerId: String) -> Int { - let next = (self.counts[providerId] ?? 0) + 1 - self.counts[providerId] = next - return next - } -} - -private final class MemoryImportGatewayConfig: @unchecked Sendable { - private let lock = NSLock() - private let url: URL - private var token: String - - init(url: URL, token: String) { - self.url = url - self.token = token - } - - func setToken(_ token: String) { - self.lock.lock() - self.token = token - self.lock.unlock() - } - - func snapshot() -> GatewayConnection.Config { - self.lock.lock() - defer { self.lock.unlock() } - return (url: self.url, token: self.token, password: nil) - } -} - -private func memoryImportWireRequest( - from message: URLSessionWebSocketTask.Message) -> MemoryImportWireRequest? -{ - let data: Data? = switch message { - case let .data(data): data - case let .string(string): string.data(using: .utf8) - @unknown default: nil - } - guard let data, - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let id = object["id"] as? String, - let method = object["method"] as? String - else { return nil } - let params = object["params"] as? [String: Any] ?? [:] - return MemoryImportWireRequest( - id: id, - method: method, - agentId: params["agentId"] as? String, - providerId: params["providerId"] as? String, - planFingerprint: params["planFingerprint"] as? String, - itemIds: params["itemIds"] as? [String] ?? [], - overwrite: params["overwrite"] as? Bool, - idempotencyKey: params["idempotencyKey"] as? String) -} - -private func memoryImportOK(id: String, payload: String) -> Data { - Data(#"{"type":"res","id":"\#(id)","ok":true,"payload":\#(payload)}"#.utf8) -} - -private func memoryImportError(id: String, message: String) -> Data { - Data( - #"{"type":"res","id":"\#(id)","ok":false,"error":{"code":"INVALID_REQUEST","message":"\#(message)"}}"#.utf8) -} - -private let memoryImportEmptyPlanPayload = #"{"agentId":"main","workspace":"/tmp/workspace","providers":[]}"# - -private let memoryImportOfferPlanPayload = #""" -{ - "agentId":"main", - "workspace":"/tmp/workspace", - "providers":[ - { - "providerId":"claude", - "label":"Claude", - "planFingerprint":"plan-claude", - "found":true, - "source":"~/.claude", - "summary":{"total":3,"planned":2,"migrated":0,"skipped":0,"conflicts":1,"errors":0,"sensitive":0}, - "items":[ - {"id":"planned-1","status":"planned"}, - {"id":"conflict-1","status":"conflict"}, - {"id":"planned-2","status":"planned"} - ] - }, - { - "providerId":"codex", - "label":"Codex", - "planFingerprint":"plan-codex", - "found":true, - "source":"~/.codex", - "summary":{"total":1,"planned":1,"migrated":0,"skipped":0,"conflicts":0,"errors":0,"sensitive":0}, - "items":[{"id":"codex-1","status":"planned"}] - } - ] -} -"""# - -private let memoryImportProviderErrorPlanPayload = #""" -{ - "agentId":"main", - "workspace":"/tmp/workspace", - "providers":[ - { - "providerId":"claude", - "label":"Claude", - "found":true, - "source":"~/.claude", - "summary":{"total":0,"planned":0,"migrated":0,"skipped":0,"conflicts":0,"errors":1,"sensitive":0}, - "items":[], - "error":"Could not read Claude memories" - } - ] -} -"""# - -private let memoryImportSummaryErrorPlanPayload = #""" -{ - "agentId":"main", - "workspace":"/tmp/workspace", - "providers":[ - { - "providerId":"claude", - "label":"Claude", - "found":true, - "summary":{"total":0,"planned":0,"migrated":0,"skipped":0,"conflicts":0,"errors":1,"sensitive":0}, - "items":[] - } - ] -} -"""# - -private let memoryImportMissingFingerprintPlanPayload = #""" -{ - "agentId":"main", - "workspace":"/tmp/workspace", - "providers":[ - { - "providerId":"claude", - "label":"Claude", - "found":true, - "source":"~/.claude", - "summary":{"total":1,"planned":1,"migrated":0,"skipped":0,"conflicts":0,"errors":0,"sensitive":0}, - "items":[{"id":"planned-1","status":"planned"}] - } - ] -} -"""# - -private let memoryImportInconsistentProviderPlanPayload = #""" -{ - "agentId":"main", - "workspace":"/tmp/workspace", - "providers":[ - { - "providerId":"claude", - "label":"Claude", - "planFingerprint":"plan-claude", - "found":true, - "summary":{"total":1,"planned":1,"migrated":0,"skipped":0,"conflicts":0,"errors":0,"sensitive":0}, - "items":[{"id":"planned-1","status":"planned"}] - }, - { - "providerId":"broken", - "label":"Broken", - "planFingerprint":"plan-broken", - "found":false, - "summary":{"total":1,"planned":1,"migrated":0,"skipped":0,"conflicts":0,"errors":0,"sensitive":0}, - "items":[{"id":"broken-1","status":"planned"}] - } - ] -} -"""# - -private let memoryImportDuplicateProviderPlanPayload = #""" -{ - "agentId":"main", - "workspace":"/tmp/workspace", - "providers":[ - { - "providerId":"duplicate", - "label":"First", - "found":false, - "summary":{"total":0,"planned":0,"migrated":0,"skipped":0,"conflicts":0,"errors":0,"sensitive":0}, - "items":[] - }, - { - "providerId":"duplicate", - "label":"Second", - "found":false, - "summary":{"total":0,"planned":0,"migrated":0,"skipped":0,"conflicts":0,"errors":0,"sensitive":0}, - "items":[] - } - ] -} -"""# - -private func memoryImportApplyPayload(providerId: String, migrated: Int, errors: Int = 0) -> String { - #"{"providerId":"\#(providerId)","source":"local","summary":{"total":\#(migrated + errors),"planned":0,"migrated":\#(migrated),"skipped":0,"conflicts":0,"errors":\#(errors),"sensitive":0},"items":[]}"# -} - -private func makeMemoryImportGateway( - configProvider: @escaping @Sendable () async throws -> GatewayConnection.Config, - responder: @escaping @Sendable (GatewayTestWebSocketTask, MemoryImportWireRequest) async -> Void) - -> GatewayConnection -{ - GatewayConnection( - configProvider: configProvider, - sessionBox: WebSocketSessionBox(session: GatewayTestWebSocketSession(taskFactory: { - GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in - guard sendIndex > 0, let request = memoryImportWireRequest(from: message) else { return } - await responder(task, request) - }) - }))) -} - -@Suite(.serialized) -@MainActor -struct OnboardingMemoryImportTests { - private func withTemporaryStateDir(_ operation: () async throws -> T) async throws -> T { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: tempDir) } - return try await DeviceIdentityStore.withStateDirectory(tempDir) { - try await operation() - } - } - - @Test func `plan maps planned and already imported memories into an offer`() async throws { - let url = try #require(URL(string: "ws://memory.test")) - let gateway = makeMemoryImportGateway( - configProvider: { (url: url, token: nil, password: nil) }, - responder: { task, request in - let payload = request.method == "health" ? "{}" : memoryImportOfferPlanPayload - task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload))) - }) - let model = OnboardingMemoryImportModel() - - await model.startPlanning(gateway: gateway, agentId: "main") - - #expect(model.hasOffer) - #expect(model.providers.count == 2) - #expect(model.providers[0].providerId == "claude") - #expect(model.providers[0].plannedCount == 2) - #expect(model.providers[0].alreadyImportedCount == 1) - #expect(model.providers[0].selected) - #expect(model.providers[1].plannedCount == 1) - } - - @Test func `empty plan resolves without an offer`() async throws { - let url = try #require(URL(string: "ws://memory.test")) - let gateway = makeMemoryImportGateway( - configProvider: { (url: url, token: nil, password: nil) }, - responder: { task, request in - let payload = request.method == "health" ? "{}" : memoryImportEmptyPlanPayload - task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload))) - }) - let model = OnboardingMemoryImportModel() - - await model.startPlanning(gateway: gateway, agentId: "main") - - #expect(model.resolvedEmpty) - #expect(!model.hasOffer) - #expect(!model.pageEligible) - } - - @Test func `provider planning error remains retryable instead of resolving empty`() async throws { - let url = try #require(URL(string: "ws://memory.test")) - let gateway = makeMemoryImportGateway( - configProvider: { (url: url, token: nil, password: nil) }, - responder: { task, request in - let payload = request.method == "migrations.memory.plan" - ? memoryImportProviderErrorPlanPayload - : "{}" - task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload))) - }) - let model = OnboardingMemoryImportModel() - - await model.startPlanning(gateway: gateway, agentId: "main") - - guard case let .failed(message) = model.phase else { - Issue.record("Expected a retryable planning failure") - return - } - #expect(message == "Claude: Could not read Claude memories") - #expect(!model.resolvedEmpty) - #expect(model.pageEligible) - } - - @Test func `provider summary error remains retryable without an error string`() async throws { - let url = try #require(URL(string: "ws://memory.test")) - let gateway = makeMemoryImportGateway( - configProvider: { (url: url, token: nil, password: nil) }, - responder: { task, request in - let payload = request.method == "migrations.memory.plan" - ? memoryImportSummaryErrorPlanPayload - : "{}" - task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload))) - }) - let model = OnboardingMemoryImportModel() - - await model.startPlanning(gateway: gateway, agentId: "main") - - guard case let .failed(message) = model.phase else { - Issue.record("Expected summary error to remain retryable") - return - } - #expect(message.contains("could not plan 1 memory")) - #expect(!model.resolvedEmpty) - } - - @Test func `missing plan fingerprint remains retryable instead of offering import`() async throws { - let url = try #require(URL(string: "ws://memory.test")) - let gateway = makeMemoryImportGateway( - configProvider: { (url: url, token: nil, password: nil) }, - responder: { task, request in - let payload = request.method == "migrations.memory.plan" - ? memoryImportMissingFingerprintPlanPayload - : "{}" - task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload))) - }) - let model = OnboardingMemoryImportModel() - - await model.startPlanning(gateway: gateway, agentId: "main") - - guard case let .failed(message) = model.phase else { - Issue.record("Expected a retryable missing-fingerprint failure") - return - } - #expect(message.contains("usable import plan")) - #expect(!model.hasOffer) - #expect(model.pageEligible) - } - - @Test func `inconsistent provider stays disabled beside a valid offer`() async throws { - let url = try #require(URL(string: "ws://memory.test")) - let gateway = makeMemoryImportGateway( - configProvider: { (url: url, token: nil, password: nil) }, - responder: { task, request in - let payload = request.method == "migrations.memory.plan" - ? memoryImportInconsistentProviderPlanPayload - : "{}" - task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload))) - }) - let model = OnboardingMemoryImportModel() - - await model.startPlanning(gateway: gateway, agentId: "main") - - #expect(model.hasOffer) - let broken = try #require(model.providers.first { $0.providerId == "broken" }) - #expect(!broken.selected) - #expect(!broken.isActionable) - #expect(broken.requiresReplan) - #expect(broken.inlineError?.contains("inconsistent") == true) - } - - @Test func `foreign agent identity rejects the plan`() async throws { - try await self.withTemporaryStateDir { - let url = try #require(URL(string: "ws://memory.test")) - let gateway = makeMemoryImportGateway( - configProvider: { (url: url, token: nil, password: nil) }, - responder: { task, request in - let payload = request.method == "migrations.memory.plan" - ? #"{"agentId":"other","workspace":"/tmp/workspace","providers":[]}"# - : "{}" - task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload))) - }) - let model = OnboardingMemoryImportModel() - - await model.startPlanning(gateway: gateway, agentId: "main") - guard case let .failed(message) = model.phase else { - Issue.record("Expected foreign-agent plan failure") - return - } - #expect(message.contains("different agent")) - } - } - - @Test func `duplicate provider identity rejects the plan`() async throws { - try await self.withTemporaryStateDir { - let url = try #require(URL(string: "ws://memory.test")) - let gateway = makeMemoryImportGateway( - configProvider: { (url: url, token: nil, password: nil) }, - responder: { task, request in - let payload = request.method == "migrations.memory.plan" - ? memoryImportDuplicateProviderPlanPayload - : "{}" - task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload))) - }) - let model = OnboardingMemoryImportModel() - - await model.startPlanning(gateway: gateway, agentId: "main") - guard case let .failed(message) = model.phase else { - Issue.record("Expected duplicate-provider plan failure") - return - } - #expect(message.contains("invalid memory provider")) - } - } - - @Test func `default agent id feeds the plan request`() async throws { - let url = try #require(URL(string: "ws://memory.test")) - let recorder = MemoryImportRequestRecorder() - let gateway = makeMemoryImportGateway( - configProvider: { (url: url, token: nil, password: nil) }, - responder: { task, request in - await recorder.record(request) - let payload = switch request.method { - case "agents.list": #"{"defaultId":"work"}"# - case "migrations.memory.plan": #"{"agentId":"work","workspace":"/tmp/workspace","providers":[]}"# - default: "{}" - } - task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload))) - }) - let model = OnboardingMemoryImportModel() - - await model.startPlanning(gateway: gateway) - - let plan = try #require(await recorder.snapshot().first { $0.method == "migrations.memory.plan" }) - #expect(plan.agentId == "work") - #expect(plan.overwrite == false) - #expect(model.resolvedEmpty) - } - - @Test func `apply sends planned ids and continues after a provider error`() async throws { - let url = try #require(URL(string: "ws://memory.test")) - let recorder = MemoryImportRequestRecorder() - let counter = MemoryImportApplyCounter() - let gateway = makeMemoryImportGateway( - configProvider: { (url: url, token: nil, password: nil) }, - responder: { task, request in - await recorder.record(request) - if request.method == "migrations.memory.apply", - request.providerId == "claude", - await counter.next(for: "claude") == 1 - { - task.emitReceiveSuccess(.data(memoryImportError(id: request.id, message: "Claude import failed"))) - return - } - let payload = switch request.method { - case "migrations.memory.plan": memoryImportOfferPlanPayload - case "migrations.memory.apply": memoryImportApplyPayload( - providerId: request.providerId ?? "unknown", - migrated: request.providerId == "claude" ? 2 : 1) - default: "{}" - } - task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload))) - }) - let model = OnboardingMemoryImportModel() - await model.startPlanning(gateway: gateway, agentId: "main") - - await model.importSelected(gateway: gateway) - - #expect(model.providers.first(where: { $0.providerId == "claude" })?.inlineError != nil) - #expect(model.providers.first(where: { $0.providerId == "codex" })?.result?.migrated == 1) - let applyRequests = await recorder.snapshot().filter { $0.method == "migrations.memory.apply" } - #expect(applyRequests.count == 2) - let claude = try #require(applyRequests.first { $0.providerId == "claude" }) - #expect(claude.agentId == "main") - #expect(claude.planFingerprint == "plan-claude") - #expect(claude.itemIds == ["planned-1", "planned-2"]) - #expect(claude.overwrite == false) - - let keys = Set(applyRequests.compactMap(\.idempotencyKey)) - #expect(keys.count == 2) - #expect(model.hasReplanRequired) - - await model.startPlanning(gateway: gateway, agentId: "main") - #expect(model.hasOffer) - #expect(!model.hasReplanRequired) - #expect(model.providers.first { $0.providerId == "claude" }?.inlineError == nil) - let carriedCodex = try #require(model.providers.first { $0.providerId == "codex" }) - #expect(carriedCodex.result?.migrated == 1) - #expect(!carriedCodex.selected) - #expect(!carriedCodex.isActionable) - } - - @Test func `ambiguous apply response reuses its idempotency key`() async throws { - let url = try #require(URL(string: "ws://memory.test")) - let recorder = MemoryImportRequestRecorder() - let counter = MemoryImportApplyCounter() - let gateway = makeMemoryImportGateway( - configProvider: { (url: url, token: nil, password: nil) }, - responder: { task, request in - await recorder.record(request) - let payload: String = if request.method == "migrations.memory.plan" { - memoryImportOfferPlanPayload - } else if request.method == "migrations.memory.apply", - await counter.next(for: request.providerId ?? "unknown") == 1 - { - "{}" - } else if request.method == "migrations.memory.apply" { - memoryImportApplyPayload( - providerId: request.providerId ?? "unknown", - migrated: 2) - } else { - "{}" - } - task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload))) - }) - let model = OnboardingMemoryImportModel() - await model.startPlanning(gateway: gateway, agentId: "main") - model.setSelected(false, providerId: "codex") - - await model.importSelected(gateway: gateway) - #expect(model.providers.first?.inlineError != nil) - await model.importSelected(gateway: gateway) - - let attempts = await recorder.snapshot().filter { $0.method == "migrations.memory.apply" } - #expect(attempts.count == 2) - #expect(attempts[0].idempotencyKey == attempts[1].idempotencyKey) - #expect(model.results.first?.migrated == 2) - } - - @Test func `ambiguous retry must resolve before a deterministic failure can replan`() async throws { - let url = try #require(URL(string: "ws://memory.test")) - let recorder = MemoryImportRequestRecorder() - let counter = MemoryImportApplyCounter() - let gateway = makeMemoryImportGateway( - configProvider: { (url: url, token: nil, password: nil) }, - responder: { task, request in - await recorder.record(request) - if request.method == "migrations.memory.apply", request.providerId == "claude" { - let attempt = await counter.next(for: "claude") - let payload = attempt == 1 - ? "{}" - : memoryImportApplyPayload(providerId: "claude", migrated: 2) - task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload))) - return - } - if request.method == "migrations.memory.apply" { - task.emitReceiveSuccess(.data(memoryImportError(id: request.id, message: "refresh required"))) - return - } - let payload = request.method == "migrations.memory.plan" ? memoryImportOfferPlanPayload : "{}" - task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload))) - }) - let model = OnboardingMemoryImportModel() - await model.startPlanning(gateway: gateway, agentId: "main") - - await model.importSelected(gateway: gateway) - - #expect(model.hasReplanRequired) - #expect(!model.canReplan) - model.setSelected(false, providerId: "claude") - model.setSelected(true, providerId: "claude") - #expect(!model.canReplan) - let firstClaudeKey = try #require(await recorder.snapshot().first { - $0.method == "migrations.memory.apply" && $0.providerId == "claude" - }?.idempotencyKey) - await model.startPlanning(gateway: gateway, agentId: "main") - #expect(await recorder.snapshot().count { $0.method == "migrations.memory.plan" } == 1) - - await model.importSelected(gateway: gateway) - - let claudeKeys = await recorder.snapshot().filter { - $0.method == "migrations.memory.apply" && $0.providerId == "claude" - }.compactMap(\.idempotencyKey) - #expect(claudeKeys == [firstClaudeKey, firstClaudeKey]) - #expect(model.canReplan) - } - - @Test func `apply response for another provider is rejected without consuming retry identity`() async throws { - let url = try #require(URL(string: "ws://memory.test")) - let recorder = MemoryImportRequestRecorder() - let gateway = makeMemoryImportGateway( - configProvider: { (url: url, token: nil, password: nil) }, - responder: { task, request in - await recorder.record(request) - let payload = switch request.method { - case "migrations.memory.plan": memoryImportOfferPlanPayload - case "migrations.memory.apply": memoryImportApplyPayload(providerId: "codex", migrated: 2) - default: "{}" - } - task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload))) - }) - let model = OnboardingMemoryImportModel() - await model.startPlanning(gateway: gateway, agentId: "main") - model.setSelected(false, providerId: "codex") - - await model.importSelected(gateway: gateway) - await model.importSelected(gateway: gateway) - - let claude = try #require(model.providers.first { $0.providerId == "claude" }) - #expect(claude.result == nil) - #expect(claude.inlineError?.contains("different memory provider") == true) - let keys = await recorder.snapshot().filter { - $0.method == "migrations.memory.apply" - }.compactMap(\.idempotencyKey) - #expect(keys.count == 2) - #expect(keys[0] == keys[1]) - } - - @Test func `fresh replan keeps completed totals while offering newly planned items`() async throws { - let url = try #require(URL(string: "ws://memory.test")) - let counter = MemoryImportApplyCounter() - let gateway = makeMemoryImportGateway( - configProvider: { (url: url, token: nil, password: nil) }, - responder: { task, request in - let payload: String - if request.method == "migrations.memory.plan" { - let attempt = await counter.next(for: "plan") - payload = attempt == 1 - ? memoryImportOfferPlanPayload - : memoryImportOfferPlanPayload - .replacingOccurrences(of: "plan-codex", with: "plan-codex-new") - .replacingOccurrences(of: "codex-1", with: "codex-new") - } else if request.method == "migrations.memory.apply", request.providerId == "claude" { - task.emitReceiveSuccess(.data(memoryImportError(id: request.id, message: "refresh required"))) - return - } else if request.method == "migrations.memory.apply" { - payload = memoryImportApplyPayload(providerId: "codex", migrated: 1) - } else { - payload = "{}" - } - task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload))) - }) - let model = OnboardingMemoryImportModel() - await model.startPlanning(gateway: gateway, agentId: "main") - await model.importSelected(gateway: gateway) - - await model.startPlanning(gateway: gateway, agentId: "main") - - let codex = try #require(model.providers.first { $0.providerId == "codex" }) - #expect(codex.result?.migrated == 1) - #expect(codex.plannedItemIds == ["codex-new"]) - #expect(codex.selected) - #expect(codex.isActionable) - } - - @Test func `reset invalidates an in flight batch before the next provider`() async throws { - let url = try #require(URL(string: "ws://memory.test")) - let recorder = MemoryImportRequestRecorder() - let gate = MemoryImportRequestGate() - let gateway = makeMemoryImportGateway( - configProvider: { (url: url, token: nil, password: nil) }, - responder: { task, request in - await recorder.record(request) - if request.method == "migrations.memory.apply" { - await gate.wait() - task.emitReceiveSuccess(.data(memoryImportOK( - id: request.id, - payload: memoryImportApplyPayload( - providerId: request.providerId ?? "unknown", - migrated: 1)))) - return - } - let payload = request.method == "migrations.memory.plan" ? memoryImportOfferPlanPayload : "{}" - task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload))) - }) - let model = OnboardingMemoryImportModel() - await model.startPlanning(gateway: gateway, agentId: "main") - let applying = Task { await model.importSelected(gateway: gateway) } - await gate.waitUntilStarted() - - model.reset() - await gate.release() - await applying.value - - #expect(model.phase == .idle) - #expect(await recorder.snapshot().count { $0.method == "migrations.memory.apply" } == 1) - } - - @Test func `replan for another agent discards completed-provider carryover`() async throws { - let url = try #require(URL(string: "ws://memory.test")) - let gateway = makeMemoryImportGateway( - configProvider: { (url: url, token: nil, password: nil) }, - responder: { task, request in - if request.method == "migrations.memory.apply", request.providerId == "claude" { - task.emitReceiveSuccess(.data(memoryImportError(id: request.id, message: "refresh required"))) - return - } - let payload = switch request.method { - case "migrations.memory.plan": request.agentId == "other" - ? memoryImportOfferPlanPayload.replacingOccurrences( - of: #""agentId":"main""#, - with: #""agentId":"other""#) - : memoryImportOfferPlanPayload - case "migrations.memory.apply": memoryImportApplyPayload(providerId: "codex", migrated: 1) - default: "{}" - } - task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload))) - }) - let model = OnboardingMemoryImportModel() - await model.startPlanning(gateway: gateway, agentId: "main") - await model.importSelected(gateway: gateway) - #expect(model.providers.first { $0.providerId == "codex" }?.result?.migrated == 1) - - await model.startPlanning(gateway: gateway, agentId: "other") - - #expect(model.providers.first { $0.providerId == "codex" }?.result == nil) - #expect(model.providers.first { $0.providerId == "codex" }?.selected == true) - } - - @Test func `failed refresh preserves fresh errors and completed carryover for retry`() async throws { - let url = try #require(URL(string: "ws://memory.test")) - let counter = MemoryImportApplyCounter() - let gateway = makeMemoryImportGateway( - configProvider: { (url: url, token: nil, password: nil) }, - responder: { task, request in - if request.method == "migrations.memory.apply", request.providerId == "codex" { - task.emitReceiveSuccess(.data(memoryImportError(id: request.id, message: "refresh required"))) - return - } - let payload: String - if request.method == "migrations.memory.plan" { - let attempt = await counter.next(for: "plan") - payload = attempt == 2 ? memoryImportProviderErrorPlanPayload : memoryImportOfferPlanPayload - } else if request.method == "migrations.memory.apply" { - payload = memoryImportApplyPayload(providerId: "claude", migrated: 2) - } else { - payload = "{}" - } - task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload))) - }) - let model = OnboardingMemoryImportModel() - await model.startPlanning(gateway: gateway, agentId: "main") - await model.importSelected(gateway: gateway) - #expect(model.providers.first { $0.providerId == "claude" }?.result?.migrated == 2) - - await model.startPlanning(gateway: gateway, agentId: "main") - guard case let .failed(message) = model.phase else { - Issue.record("Expected the fresh provider error to fail refresh") - return - } - #expect(message.contains("Could not read Claude memories")) - - await model.startPlanning(gateway: gateway, agentId: "main") - - let claude = try #require(model.providers.first { $0.providerId == "claude" }) - #expect(claude.result?.migrated == 2) - #expect(!claude.selected) - } - - @Test func `apply rejects an offer from a replaced server lease`() async throws { - let url = try #require(URL(string: "ws://memory.test")) - let config = MemoryImportGatewayConfig(url: url, token: "first") - let recorder = MemoryImportRequestRecorder() - let gateway = makeMemoryImportGateway( - configProvider: { config.snapshot() }, - responder: { task, request in - await recorder.record(request) - let payload = request.method == "migrations.memory.plan" ? memoryImportOfferPlanPayload : "{}" - task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload))) - }) - let model = OnboardingMemoryImportModel() - await model.startPlanning(gateway: gateway, agentId: "main") - - config.setToken("replacement") - await model.importSelected(gateway: gateway) - - #expect(model.isFailed) - #expect(await recorder.snapshot().allSatisfy { $0.method != "migrations.memory.apply" }) - } - - @Test func `stale lease plan response is ignored`() async throws { - let url = try #require(URL(string: "ws://memory.test")) - let config = MemoryImportGatewayConfig(url: url, token: "first") - let gate = MemoryImportRequestGate() - let gateway = makeMemoryImportGateway( - configProvider: { config.snapshot() }, - responder: { task, request in - if request.method == "migrations.memory.plan" { - await gate.wait() - task.emitReceiveSuccess(.data(memoryImportOK( - id: request.id, - payload: memoryImportOfferPlanPayload))) - return - } - task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: "{}"))) - }) - let model = OnboardingMemoryImportModel() - let planning = Task { await model.startPlanning(gateway: gateway, agentId: "main") } - await gate.waitUntilStarted() - - config.setToken("replacement") - await gate.release() - await planning.value - - guard case .failed = model.phase else { - Issue.record("Expected a retryable failure after the stale plan response") - return - } - #expect(!model.hasOffer) - #expect(model.pageEligible) - } - - @Test func `active empty page requests auto advance before becoming ineligible`() async throws { - let url = try #require(URL(string: "ws://memory.test")) - let gateway = makeMemoryImportGateway( - configProvider: { (url: url, token: nil, password: nil) }, - responder: { task, request in - let payload = request.method == "health" ? "{}" : memoryImportEmptyPlanPayload - task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload))) - }) - let model = OnboardingMemoryImportModel() - model.setPageActive(true) - - await model.startPlanning(gateway: gateway, agentId: "main") - - #expect(model.resolvedEmpty) - #expect(model.autoAdvanceRequested) - #expect(model.pageEligible) - model.consumeAutoAdvanceRequest() - #expect(!model.autoAdvanceRequested) - #expect(!model.pageEligible) - } - - @Test func `dismissed planning failure stays ineligible for automatic retry`() async throws { - let url = try #require(URL(string: "ws://memory.test")) - let gateway = makeMemoryImportGateway( - configProvider: { (url: url, token: nil, password: nil) }, - responder: { task, request in - if request.method == "migrations.memory.plan" { - task.emitReceiveSuccess(.data(memoryImportError(id: request.id, message: "planning failed"))) - return - } - task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: "{}"))) - }) - let model = OnboardingMemoryImportModel() - await model.startPlanning(gateway: gateway, agentId: "main") - #expect(model.isFailed) - - model.dismissFailure() - - #expect(!model.pageEligible) - #expect(!model.shouldStartAutomatically) - } - - @Test func `partial apply result stays in offer with an inline error`() async throws { - let url = try #require(URL(string: "ws://memory.test")) - let gateway = makeMemoryImportGateway( - configProvider: { (url: url, token: nil, password: nil) }, - responder: { task, request in - let payload = switch request.method { - case "migrations.memory.plan": memoryImportOfferPlanPayload - case "migrations.memory.apply": memoryImportApplyPayload( - providerId: request.providerId ?? "unknown", - migrated: 1, - errors: request.providerId == "claude" ? 1 : 0) - default: "{}" - } - task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload))) - }) - let model = OnboardingMemoryImportModel() - await model.startPlanning(gateway: gateway, agentId: "main") - model.setSelected(false, providerId: "codex") - - await model.importSelected(gateway: gateway) - - let claude = try #require(model.providers.first { $0.providerId == "claude" }) - #expect(claude.result?.migrated == 1) - #expect(claude.result?.errors == 1) - #expect(claude.inlineError == "1 memory could not be imported.") - #expect(!claude.selected) - #expect(claude.requiresReplan) - - model.setSelected(true, providerId: "codex") - await model.importSelected(gateway: gateway) - - let retainedClaude = try #require(model.providers.first { $0.providerId == "claude" }) - let codex = try #require(model.providers.first { $0.providerId == "codex" }) - #expect(retainedClaude.inlineError == "1 memory could not be imported.") - #expect(codex.result?.migrated == 1) - #expect(model.hasReplanRequired) - - await model.startPlanning(gateway: gateway, agentId: "main") - - let refreshedClaude = try #require(model.providers.first { $0.providerId == "claude" }) - let refreshedCodex = try #require(model.providers.first { $0.providerId == "codex" }) - #expect(refreshedClaude.result?.migrated == 1) - #expect(refreshedClaude.selected) - #expect(refreshedCodex.result?.migrated == 1) - #expect(!refreshedCodex.selected) - - model.setSelected(false, providerId: "claude") - #expect(model.providers.first { $0.providerId == "claude" }?.selected == false) - } - - @Test func `stale lease apply response exits applying without accepting results`() async throws { - let url = try #require(URL(string: "ws://memory.test")) - let config = MemoryImportGatewayConfig(url: url, token: "first") - let gate = MemoryImportRequestGate() - let gateway = makeMemoryImportGateway( - configProvider: { config.snapshot() }, - responder: { task, request in - if request.method == "migrations.memory.apply" { - await gate.wait() - task.emitReceiveSuccess(.data(memoryImportOK( - id: request.id, - payload: memoryImportApplyPayload(providerId: request.providerId ?? "unknown", migrated: 1)))) - return - } - let payload = request.method == "migrations.memory.plan" ? memoryImportOfferPlanPayload : "{}" - task.emitReceiveSuccess(.data(memoryImportOK(id: request.id, payload: payload))) - }) - let model = OnboardingMemoryImportModel() - await model.startPlanning(gateway: gateway, agentId: "main") - let applying = Task { await model.importSelected(gateway: gateway) } - await gate.waitUntilStarted() - - config.setToken("replacement") - await gate.release() - await applying.value - - #expect(model.isFailed) - #expect(!model.isApplying) - #expect(model.results.isEmpty) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift index 66dfdf3baec6..9980eed3ec4e 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift @@ -101,97 +101,27 @@ struct OnboardingViewSmokeTests { #expect(scrollView.documentView != nil) } - @Test func `local page order includes memory import only while eligible`() { - let configuredOrder = OnboardingView.pageOrder( + @Test func `configured flows end at AI setup and hand off to the dashboard`() { + // Everything after working inference (memory import, permissions, + // channels, hatch) belongs to the dashboard custodian onboarding. + #expect(OnboardingView.pageOrder( for: .local, - requiresCLIInstall: false, - memoryImportEligible: true) - let freshOrder = OnboardingView.pageOrder( + requiresCLIInstall: true) == [0, 1, 2, 3]) + #expect(OnboardingView.pageOrder( for: .local, - requiresCLIInstall: true, - memoryImportEligible: true) - let resolvedEmptyOrder = OnboardingView.pageOrder( - for: .local, - requiresCLIInstall: false, - memoryImportEligible: false) - - #expect(configuredOrder == [0, 1, 3, 4, 5, 9]) - #expect(freshOrder == [0, 1, 2, 3, 4, 5, 9]) - #expect(resolvedEmptyOrder == [0, 1, 3, 5, 9]) - #expect(!configuredOrder.contains(7)) - #expect(!configuredOrder.contains(8)) + requiresCLIInstall: false) == [0, 1, 3]) + #expect(OnboardingView.pageOrder( + for: .remote, + requiresCLIInstall: true) == [0, 1, 2, 3]) + #expect(OnboardingView.pageOrder( + for: .remote, + requiresCLIInstall: false) == [0, 1, 3]) } - @Test func `remote and unconfigured page orders never include memory import`() { - #expect(OnboardingView.pageOrder( - for: .remote, - requiresCLIInstall: true, - memoryImportEligible: true) == [0, 1, 2, 3, 5, 9]) - #expect(OnboardingView.pageOrder( - for: .remote, - requiresCLIInstall: false, - memoryImportEligible: true) == [0, 1, 3, 5, 9]) + @Test func `set up later keeps the native ready page`() { #expect(OnboardingView.pageOrder( for: .unconfigured, - requiresCLIInstall: false, - memoryImportEligible: true) == [0, 1, 9]) - } - - @Test func `memory page inclusion follows local model eligibility`() { - let withMemory = OnboardingView.pageOrder( - for: .local, - requiresCLIInstall: false, - memoryImportEligible: true) - - #expect(OnboardingView.shouldIncludeMemoryImportPage( - for: .local, - modelEligible: true)) - #expect(!OnboardingView.shouldIncludeMemoryImportPage( - for: .local, - modelEligible: false)) - #expect(!OnboardingView.shouldIncludeMemoryImportPage( - for: .remote, - modelEligible: true)) - let withoutMemory = OnboardingView.pageOrder( - for: .local, - requiresCLIInstall: false, - memoryImportEligible: false) - #expect(withMemory.prefix(3) == withoutMemory.prefix(3)) - #expect(!withoutMemory.contains(4)) - } - - @Test func `memory page removal preserves the active logical page`() throws { - let previousOrder = OnboardingView.pageOrder( - for: .local, - requiresCLIInstall: false, - memoryImportEligible: true) - let newOrder = OnboardingView.pageOrder( - for: .local, - requiresCLIInstall: false, - memoryImportEligible: false) - let aiCursor = try #require(previousOrder.firstIndex(of: 3)) - let memoryCursor = try #require(previousOrder.firstIndex(of: 4)) - let permissionsCursor = try #require(previousOrder.firstIndex(of: 5)) - let readyCursor = try #require(previousOrder.firstIndex(of: 9)) - let newPermissionsCursor = try #require(newOrder.firstIndex(of: 5)) - let newReadyCursor = try #require(newOrder.firstIndex(of: 9)) - - #expect(OnboardingView.reconciledPageCursor( - currentPage: aiCursor, - previousOrder: previousOrder, - newOrder: newOrder) == aiCursor) - #expect(OnboardingView.reconciledPageCursor( - currentPage: memoryCursor, - previousOrder: previousOrder, - newOrder: newOrder) == newPermissionsCursor) - #expect(OnboardingView.reconciledPageCursor( - currentPage: permissionsCursor, - previousOrder: previousOrder, - newOrder: newOrder) == newPermissionsCursor) - #expect(OnboardingView.reconciledPageCursor( - currentPage: readyCursor, - previousOrder: previousOrder, - newOrder: newOrder) == newReadyCursor) + requiresCLIInstall: false) == [0, 1, 9]) } @Test func `fresh local setup installs CLI before inference setup`() { @@ -322,14 +252,13 @@ struct OnboardingViewSmokeTests { #expect(monitoredPage == view.activePageIndex) } - @Test func `gateway route reset returns later pages to inference setup`() throws { + @Test func `gateway route reset keeps the AI page blocking until inference verifies`() throws { let order = OnboardingView.pageOrder( for: .remote, requiresCLIInstall: false) - let permissionsCursor = try #require(order.firstIndex(of: 5)) let aiCursor = try #require(order.firstIndex(of: 3)) let resetCursor = OnboardingView.pageCursorAfterGatewayReset( - currentPage: permissionsCursor, + currentPage: order.count - 1, pageOrder: order, aiPageIndex: 3) diff --git a/apps/macos/Tests/OpenClawIPCTests/TextSummarySupportTests.swift b/apps/macos/Tests/OpenClawIPCTests/TextSummarySupportTests.swift new file mode 100644 index 000000000000..edfeb2a93ce0 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/TextSummarySupportTests.swift @@ -0,0 +1,50 @@ +import Testing +@testable import OpenClaw + +struct TextSummarySupportTests { + @Test func `keeps the last line for plain output`() { + #expect(TextSummarySupport.summarizeLastLine("first\nsecond") == "second") + } + + @Test func `returns nil for blank output`() { + #expect(TextSummarySupport.summarizeLastLine(" \n\t\n") == nil) + } + + @Test func `truncates long lines`() { + let summary = TextSummarySupport.summarizeLastLine(String(repeating: "x", count: 300)) + #expect(summary?.count == 200) + #expect(summary?.hasSuffix("…") == true) + } + + @Test func `surfaces the error line instead of the Node version banner`() { + let nodeFatal = """ + node:internal/modules/cjs/loader:1215 + throw err; + ^ + + Error: Cannot find module '/Users/example/dist/index.js' + at Function._resolveFilename (node:internal/modules/cjs/loader:1212:15) + at node:internal/main/run_main_module:36:49 { + code: 'MODULE_NOT_FOUND' + } + + Node.js v26.5.1 + """ + #expect(TextSummarySupport.summarizeLastLine(nodeFatal) + == "Error: Cannot find module '/Users/example/dist/index.js'") + } + + @Test func `falls back to the last real line when no error line precedes the banner`() { + let output = "some diagnostic\nNode.js v26.5.1" + #expect(TextSummarySupport.summarizeLastLine(output) == "some diagnostic") + } + + @Test func `banner-only output keeps the banner`() { + #expect(TextSummarySupport.summarizeLastLine("Node.js v26.5.1") == "Node.js v26.5.1") + } + + @Test func `does not jump to old error lines without a banner`() { + let output = "Error: transient\nretrying\ndone" + #expect(TextSummarySupport.summarizeLastLine(output) == "done") + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DashboardRouteMap.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DashboardRouteMap.swift index 60bc9d809953..f466beaf5ea6 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DashboardRouteMap.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DashboardRouteMap.swift @@ -6,6 +6,9 @@ public enum DashboardRouteMap { public static let cronJobsPagePath = "/cron" public static let sessionsPagePath = "/sessions" public static let devicesSettingsPath = "/settings/devices" + public static let custodianPagePath = "/custodian" + /// Control UI query that renders /custodian with onboarding chrome. + public static let custodianOnboardingSearch = "?onboarding=1" public static func isValidSameAppPath(_ path: String) -> Bool { guard path.hasPrefix("/"), !path.hasPrefix("//"), @@ -19,8 +22,18 @@ public enum DashboardRouteMap { components.fragment == nil } + /// A same-app search must stay a plain query: no scheme/host smuggling and + /// no fragment, which the dashboard URL reserves for the auth token. + public static func isValidSameAppSearch(_ search: String) -> Bool { + guard search.hasPrefix("?"), !search.contains("#") else { return false } + var components = URLComponents() + components.percentEncodedQuery = String(search.dropFirst()) + return components.percentEncodedQuery != nil + } + public static func dashboardURL( byAppendingSameAppPath path: String, + search: String? = nil, to baseURL: URL) -> URL? { guard self.isValidSameAppPath(path), @@ -28,6 +41,10 @@ public enum DashboardRouteMap { else { return nil } + if let search { + guard self.isValidSameAppSearch(search) else { return nil } + components.percentEncodedQuery = String(search.dropFirst()) + } let basePath = components.path.hasSuffix("/") ? components.path : components.path + "/" components.path = basePath + path.dropFirst() return components.url diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DashboardRouteMapTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DashboardRouteMapTests.swift index b33667872cab..b6d00ea29051 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DashboardRouteMapTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DashboardRouteMapTests.swift @@ -36,4 +36,27 @@ struct DashboardRouteMapTests { #expect(url.absoluteString == "http://127.0.0.1:18789/control/settings/channels#token=test-token") } + + @Test func `Dashboard URL carries a same-app search alongside the token fragment`() throws { + let baseURL = try #require(URL(string: "http://127.0.0.1:18789/control/#token=test-token")) + let url = try #require(DashboardRouteMap.dashboardURL( + byAppendingSameAppPath: DashboardRouteMap.custodianPagePath, + search: DashboardRouteMap.custodianOnboardingSearch, + to: baseURL)) + + #expect(url.absoluteString == "http://127.0.0.1:18789/control/custodian?onboarding=1#token=test-token") + } + + @Test(arguments: ["", "onboarding=1", "?onboarding=1#x", "?a=b#frag"]) + func `same-app search validation rejects non-query input`(_ search: String) throws { + #expect(!DashboardRouteMap.isValidSameAppSearch(search)) + #expect(try DashboardRouteMap.dashboardURL( + byAppendingSameAppPath: DashboardRouteMap.custodianPagePath, + search: search, + to: #require(URL(string: "http://127.0.0.1:18789/control/"))) == nil) + } + + @Test func `same-app search validation accepts a plain query`() { + #expect(DashboardRouteMap.isValidSameAppSearch("?onboarding=1")) + } } diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index 27ec55056738..a4d15850c281 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -38,9 +38,12 @@ has no macOS app asset, use the newest one that does, or build from source with 2. Pick **This Mac** for a local Gateway, or connect to a remote Gateway. 3. Wait while the app installs the matching CLI runtime. In local mode it also installs and starts the Gateway. -4. Establish inference with a live model check. After it passes, OpenClaw - handles the remaining setup. -5. Complete the macOS permission checklist and send the onboarding test message. +4. Establish inference with a live model check. If the app reused a login you + did not want, **Choose a different AI** on the success banner reopens the + picker, including the API-key option. +5. Finish. The app opens the dashboard, where OpenClaw guides the rest of the + setup (memory import, channels, permissions) in one conversation. Grant + macOS permissions any time from **Settings → Permissions**. If the app reaches an existing Gateway whose default agent has a configured model, it treats that Gateway as already set up, skips provider onboarding and diff --git a/docs/start/onboarding-redesign.md b/docs/start/onboarding-redesign.md index b8003cccf133..e95b2e10d4cd 100644 --- a/docs/start/onboarding-redesign.md +++ b/docs/start/onboarding-redesign.md @@ -260,6 +260,12 @@ extraction); event-reactive commentary and channel summon/agent-down recovery (phase 6 PR2); automatic `localModelLean` for weak models; whether existing users' saved sidebar pins should adopt the OpenClaw entry. +The macOS app now follows the same browser-first principle: native onboarding +ends once inference verifies (install + AI setup pages), and Finish opens the +dashboard at `/custodian?onboarding=1`. The native memory-import and +permissions pages left the first-run flow (Settings → Permissions remains); +deleting the now-unreachable native memory-import module is a follow-up. + ## Testing and landing playbook (hard-won; read before phases 4-6) - **`OPENCLAW_STATE_DIR` does not isolate the Gateway service.** The diff --git a/ui/src/app/app-host-native-shell.test.ts b/ui/src/app/app-host-native-shell.test.ts index c7fa3628bfb3..f6f07fdb25f2 100644 --- a/ui/src/app/app-host-native-shell.test.ts +++ b/ui/src/app/app-host-native-shell.test.ts @@ -192,6 +192,47 @@ describe("OpenClaw native shell", () => { expect(navigate).toHaveBeenCalledExactlyOnceWith("channels", undefined); }); + it("carries a native same-app search into navigation", () => { + const navigate = vi.fn(); + const shell = document.createElement("openclaw-app-shell") as unknown as ShellNavigationState; + shell.runtime = { + context: { + navigate, + } as unknown as ApplicationContext, + }; + const event = new CustomEvent("openclaw:native-navigate", { + cancelable: true, + detail: { path: "/custodian", search: "?onboarding=1" }, + }); + + shell.handleNativeNavigate(event); + + expect(event.defaultPrevented).toBe(true); + expect(navigate).toHaveBeenCalledExactlyOnceWith("custodian", { search: "?onboarding=1" }); + }); + + it.each(["#frag-only", "onboarding=1", "?onboarding=1#x"])( + "ignores malformed native search %s and keeps the plain route", + (search) => { + const navigate = vi.fn(); + const shell = document.createElement("openclaw-app-shell") as unknown as ShellNavigationState; + shell.runtime = { + context: { + navigate, + } as unknown as ApplicationContext, + }; + const event = new CustomEvent("openclaw:native-navigate", { + cancelable: true, + detail: { path: "/custodian", search }, + }); + + shell.handleNativeNavigate(event); + + expect(event.defaultPrevented).toBe(true); + expect(navigate).toHaveBeenCalledExactlyOnceWith("custodian", undefined); + }, + ); + it.each(["https://example.com", "//example.com", "/https://example.com", "/unknown"])( "leaves invalid native Dashboard path %s unhandled", (path) => { diff --git a/ui/src/app/app-shell-chrome.ts b/ui/src/app/app-shell-chrome.ts index f2ff8b09ee64..c4de745a3860 100644 --- a/ui/src/app/app-shell-chrome.ts +++ b/ui/src/app/app-shell-chrome.ts @@ -239,7 +239,8 @@ export class ShellChromeOwner { }; readonly handleNativeNavigate = (event: Event): void => { - const path = (event as CustomEvent<{ path?: unknown }>).detail?.path; + const detail = (event as CustomEvent<{ path?: unknown; search?: unknown }>).detail; + const path = detail?.path; const schemeCandidate = typeof path === "string" ? path.slice(1) : ""; if ( typeof path !== "string" || @@ -255,6 +256,13 @@ export class ShellChromeOwner { return; } event.preventDefault(); + // Native callers may request route chrome via a query (e.g. the macOS + // onboarding handoff lands on /custodian?onboarding=1). + const search = detail?.search; + if (typeof search === "string" && search.startsWith("?") && !search.includes("#")) { + this.host.navigate(routeId, { search }); + return; + } this.host.navigate(routeId); };