From 567abfa7f03cfdf480f421a46604ea17d6aa5774 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 11 May 2026 07:52:56 -0700 Subject: [PATCH 01/16] fix(onboarding): restore post-wizard handoff and chat connection; properly bootstrap hatching prompt Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../expertise/onboarding-chat-bootstrap.md | 21 ++ .../Onboarding/OnboardingWindow.cs | 13 +- .../Pages/ChatPage.xaml.cs | 43 ++- .../Services/BootstrapMessageInjector.cs | 274 +++++------------- .../Services/StartupSetupState.cs | 2 +- .../Windows/ChatWindow.xaml.cs | 11 +- .../BootstrapMessageInjectorTests.cs | 25 ++ .../StartupSetupStateTests.cs | 20 +- 8 files changed, 204 insertions(+), 205 deletions(-) create mode 100644 .squad/agents/aaron/expertise/onboarding-chat-bootstrap.md diff --git a/.squad/agents/aaron/expertise/onboarding-chat-bootstrap.md b/.squad/agents/aaron/expertise/onboarding-chat-bootstrap.md new file mode 100644 index 000000000..993a2536e --- /dev/null +++ b/.squad/agents/aaron/expertise/onboarding-chat-bootstrap.md @@ -0,0 +1,21 @@ +# Onboarding chat bootstrap handoff + +## Handoff chain + +The post-wizard chat path is: `OnboardingWindow.OnWizardComplete()` detects that the user finished from `OnboardingRoute.Ready` and that `StartupSetupState.RequiresSetup(...)` is false, then calls `ShowHubChatAfterWizardClose()`. That defers onto the UI dispatcher and calls `App.ShowHub("chat")`, which creates/shows the `HubWindow` and routes to `HubWindow.NavigateTo("chat")`; the chat navigation constructs/initializes `ChatPage`, and `ChatPage.Initialize(...)` loads the gateway WebView and invokes `BootstrapMessageInjector` after successful navigation. + +## Hidden contract + +The injector is in the post-wizard launch chain. Any exception from injection dispatch, selector logic, WebView script execution, timing, or settings persistence must be swallowed/logged and must not prevent the Hub/Chat auto-launch. `BootstrapMessageInjector.InjectAsync(...)` catches `OperationCanceledException` and all other exceptions and returns `false`; `ChatPage`, `ChatWindow`, and the legacy onboarding overlay also wrap the fire-and-forget dispatch site so no injector failure can bubble into window/navigation code. + +## PR archeology + +PR #274 (`581f78d276e1e6569f6385a9a991b60467c4c6e5`) introduced the local WSL onboarding flow and removed the in-wizard chat preview from the page order. That created the hidden contract that first-run completion from Ready should open the Hub chat tab after the wizard closes, while still preserving a one-shot BOOTSTRAP.md kickoff. PR #307 (`7f49beb0f571ca7ebbaf1e36bc1c685bcbf06b49`) touched only `BootstrapMessageInjector` and its tests; it tried to verify rendered composer text before consuming the gate, but over-specialized the JS selectors to a particular captured composer shape and left the injector coupled tightly enough that a failure could break the perceived post-wizard handoff. + +## Live-DOM / selector evidence + +This fix deliberately does not depend on one exact live chat composer DOM. The prior narrow selectors (`textarea[placeholder="Message Assistant (Enter to send)"]`, `textarea[aria-describedby="chat-slash-active-announcement"]`, and `.chat-send-btn`) came from the failed #307-era approach and are too brittle for the gateway chat UI. Instead, the injector now discovers broad chat-composer primitives across the document and open shadow roots: enabled `textarea`, text `input`, plain `input`, `[contenteditable="true"]`, and `[role="textbox"]`, then tries form submission, explicit send buttons, and submit buttons. Because the final approach avoids a DOM-shape-specific matcher, live-DOM capture is not blocking for correctness; visual smoke did capture the onboarding startup page at `visual-test-output\verify\page-00.png`, and no bootstrap/onboarding exception lines were present in `%LOCALAPPDATA%\OpenClawTray\openclaw-tray.log` during launch. + +## Matchers and timing + +`BootstrapMessageInjector.BuildInjectionScript(...)` encodes the message with `JsonSerializer.Serialize` so BOOTSTRAP text cannot break out of JS string context. Discovery walks `document` plus open `shadowRoot`s, filters to visible/enabled/editable elements, sets native input values (or `textContent` for contenteditable), dispatches `input` and `change`, and verifies the composer contains the message before consuming `SettingsManager.HasInjectedFirstRunBootstrap`. `ChatPage` and `ChatWindow` call injection after successful navigation with a 500ms delay; the legacy onboarding overlay still uses the service default 3000ms delay. The one-shot gate is consumed only for `sent` or `rendered`, so a missing input or failed render retries on the next chat visit instead of permanently losing the hatching prompt. diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs index 8e45e583b..909a537b8 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs @@ -531,9 +531,16 @@ private async Task SendBootstrapMessageAsync() if (_bootstrapSent || _chatWebView?.CoreWebView2 == null) return; _bootstrapSent = true; - await BootstrapMessageInjector.InjectAsync( - script => _chatWebView.CoreWebView2.ExecuteScriptAsync(script).AsTask(), - _settings); + try + { + await BootstrapMessageInjector.InjectAsync( + script => _chatWebView.CoreWebView2.ExecuteScriptAsync(script).AsTask(), + _settings); + } + catch (Exception ex) + { + Logger.Warn($"[OnboardingChat] Bootstrap injection dispatch failed: {ex.Message}"); + } } /// diff --git a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs index 9eb74f96c..92af45a0f 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs @@ -8,7 +8,9 @@ using System; using System.Diagnostics; using System.IO; +using System.Runtime.InteropServices.WindowsRuntime; using System.Threading.Tasks; +using Windows.Storage.Streams; namespace OpenClawTray.Pages; @@ -114,8 +116,16 @@ private async Task InitializeWebViewAsync(SettingsManager settings) document.head.appendChild(style); })(); "); - BootstrapMessageInjector.ScriptExecutor exec = script => WebView.CoreWebView2.ExecuteScriptAsync(script).AsTask(); - _ = BootstrapMessageInjector.InjectAsync(exec, ((App)Application.Current).Settings, initialDelayMs: 500); + try + { + BootstrapMessageInjector.ScriptExecutor exec = script => WebView.CoreWebView2.ExecuteScriptAsync(script).AsTask(); + _ = BootstrapMessageInjector.InjectAsync(exec, ((App)Application.Current).Settings, initialDelayMs: 500); + _ = CaptureVisualTestChatAsync(); + } + catch (Exception ex) + { + Logger.Warn($"[ChatPage] Bootstrap injection dispatch failed: {ex.Message}"); + } } else if (e.WebErrorStatus == CoreWebView2WebErrorStatus.ConnectionAborted || e.WebErrorStatus == CoreWebView2WebErrorStatus.CannotConnect || @@ -150,6 +160,35 @@ private async Task InitializeWebViewAsync(SettingsManager settings) } } + private async Task CaptureVisualTestChatAsync() + { + if (Environment.GetEnvironmentVariable("OPENCLAW_VISUAL_TEST") != "1") return; + if (WebView.CoreWebView2 == null) return; + + try + { + await Task.Delay(5000); + var outputDir = Environment.GetEnvironmentVariable("OPENCLAW_VISUAL_TEST_DIR"); + if (string.IsNullOrWhiteSpace(outputDir)) return; + + Directory.CreateDirectory(outputDir); + var path = Path.Combine(outputDir, $"chat-{DateTime.Now:yyyyMMddHHmmss}.png"); + using var stream = new InMemoryRandomAccessStream(); + await WebView.CoreWebView2.CapturePreviewAsync(CoreWebView2CapturePreviewImageFormat.Png, stream); + stream.Seek(0); + var reader = new DataReader(stream); + await reader.LoadAsync((uint)stream.Size); + var bytes = new byte[stream.Size]; + reader.ReadBytes(bytes); + await File.WriteAllBytesAsync(path, bytes); + Logger.Info($"[VisualTest] Captured chat WebView {path}"); + } + catch (Exception ex) + { + Logger.Warn($"[VisualTest] Chat WebView capture failed: {ex.Message}"); + } + } + private static bool TryBuildChatUrl(string gatewayUrl, string token, out string url, out string errorMessage) { url = string.Empty; diff --git a/src/OpenClaw.Tray.WinUI/Services/BootstrapMessageInjector.cs b/src/OpenClaw.Tray.WinUI/Services/BootstrapMessageInjector.cs index 61f7a5494..7679f870c 100644 --- a/src/OpenClaw.Tray.WinUI/Services/BootstrapMessageInjector.cs +++ b/src/OpenClaw.Tray.WinUI/Services/BootstrapMessageInjector.cs @@ -43,128 +43,74 @@ public static class BootstrapMessageInjector "how we should talk (web-only, WhatsApp, or Telegram)."; /// - /// Builds the JS payload that locates the chat input (traversing shadow DOMs - /// so it works against the Lit-based gateway chat UI), injects the message, - /// and tries to send it via the input's own form/composer controls. The - /// message is encoded via JsonSerializer to prevent JS template/string injection. + /// Builds the JS payload that locates the chat input using broad composer + /// primitives (including open shadow roots), injects the message, and tries + /// to send it via the input's own form/composer controls. The message is + /// encoded via JsonSerializer to prevent JS template/string injection. /// public static string BuildInjectionScript(string message) { var safeMsg = JsonSerializer.Serialize(message); return $$""" - (async function() { + (function() { const msg = {{safeMsg}}; - const seen = new Set(); - const attempts = [0, 1500]; - const pollCount = 5; - const pollDelayMs = 200; - - function walk(root, visit) { - if (!root || seen.has(root)) return null; - seen.add(root); - const found = visit(root); - if (found) return found; - const elements = root.querySelectorAll ? root.querySelectorAll('*') : []; - for (const el of elements) { - if (el.shadowRoot) { - const nested = walk(el.shadowRoot, visit); - if (nested) return nested; - } - } - return null; - } function isVisible(el) { - return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); + return !!(el && (el.offsetWidth || el.offsetHeight || el.getClientRects().length)); } - function isUsableInput(el) { - return isVisible(el) && !el.disabled && !el.readOnly; - } - - function findInput(root) { - seen.clear(); - return walk(root, r => { - const inputs = r.querySelectorAll( - 'textarea, input[type="text"], input:not([type]), [contenteditable="true"], [role="textbox"]'); - return Array.from(inputs).find(isUsableInput) || null; - }); - } - - function getRoot(el) { - return (el && el.getRootNode && el.getRootNode()) || document; - } - - function findForm(input) { - if (!input) return null; - if (input.form) return input.form; - if (input.closest) { - const direct = input.closest('form'); - if (direct) return direct; + function allCandidateElements(selectors) { + const found = []; + const roots = [document]; + const seen = new Set(); + while (roots.length) { + const root = roots.shift(); + if (!root || seen.has(root)) continue; + seen.add(root); + for (const selector of selectors) { + try { + found.push(...Array.from(root.querySelectorAll(selector))); + } catch { } + } + for (const el of Array.from(root.querySelectorAll('*'))) { + if (el.shadowRoot) roots.push(el.shadowRoot); + } } - - const root = getRoot(input); - const host = root && root.host; - return host && host.closest ? host.closest('form') : null; + return found; } - function isSendButton(btn) { - if (!btn || !isVisible(btn) || btn.disabled || btn.getAttribute('aria-disabled') === 'true') return false; - const text = (btn.textContent || '').trim().toLowerCase(); - const label = (btn.getAttribute('aria-label') || '').trim().toLowerCase(); - const title = (btn.getAttribute('title') || '').trim().toLowerCase(); - const type = (btn.getAttribute('type') || '').trim().toLowerCase(); - - return type === 'submit' || - label === 'send' || label === 'send message' || label.includes('send message') || - title === 'send' || title === 'send message' || title.includes('send message') || - text === 'send' || text === '➤' || text === '↑'; + function isEditable(el) { + return !!(el && isVisible(el) && !el.disabled && !el.readOnly && + el.getAttribute('aria-disabled') !== 'true'); } - function findComposerContainer(input) { + function findInput() { const selectors = [ - 'form', - '[role="form"]', - '[data-composer]', - '[data-testid*="composer" i]', - '[class*="composer" i]', - '[class*="chat-input" i]', - '[class*="message-input" i]' + 'textarea:not([disabled])', + 'input[type="text"]:not([disabled])', + 'input:not([type]):not([disabled])', + '[contenteditable="true"]', + '[role="textbox"]' ]; - - for (const selector of selectors) { - if (input.closest) { - const container = input.closest(selector); - if (container) return container; - } - } - - return input.parentElement || getRoot(input); + return allCandidateElements(selectors).find(isEditable) || null; } - function findSendButton(input) { - const form = findForm(input); - if (form) { - const formButton = Array.from(form.querySelectorAll('button:not([disabled]), [role="button"]:not([aria-disabled="true"])')) - .find(isSendButton); - if (formButton) return formButton; - } - - const container = findComposerContainer(input); - const roots = [container, getRoot(input)].filter(Boolean); - for (const root of roots) { - const buttons = root.querySelectorAll - ? Array.from(root.querySelectorAll('button:not([disabled]), [role="button"]:not([aria-disabled="true"])')) - : []; - const button = buttons.find(isSendButton); - if (button) return button; - } - - return null; + function findSendButton() { + const selectors = [ + 'button.chat-send-btn[aria-label="Send message"]', + 'button.chat-send-btn[title="Send"]', + 'button[aria-label="Send message"]', + 'button[title="Send"]', + 'button[aria-label*="Send" i]', + 'button[title*="Send" i]', + 'button[type="submit"]' + ]; + return allCandidateElements(selectors) + .find(el => isVisible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true') || null; } function setNativeValue(el, value) { - if (el.isContentEditable) { + if (el.isContentEditable || el.getAttribute('contenteditable') === 'true') { el.textContent = value; return; } @@ -175,116 +121,51 @@ function setNativeValue(el, value) { setter ? setter.call(el, value) : (el.value = value); } - function getInputValue(el) { - if (!el) return ''; - if (el.isContentEditable) return el.textContent || ''; - return el.value || el.textContent || ''; - } - - function normalize(value) { - return (value || '').replace(/\s+/g, ' ').trim(); - } - - function collectVisibleText(root) { - if (!root || seen.has(root)) return ''; - seen.add(root); - - let text = ''; - if (root.nodeType === Node.TEXT_NODE) { - const parent = root.parentElement; - if (parent && isVisible(parent)) text += root.nodeValue || ''; + function getRenderedValue(el) { + if (el.isContentEditable || el.getAttribute('contenteditable') === 'true') { + return el.innerText || el.textContent || ''; } - - const elements = root.querySelectorAll ? root.querySelectorAll('*') : []; - for (const el of elements) { - if (!isVisible(el)) continue; - if (el.matches && el.matches('textarea,input,[contenteditable="true"],[role="textbox"]')) continue; - text += ' ' + (el.childNodes && Array.from(el.childNodes) - .filter(n => n.nodeType === Node.TEXT_NODE) - .map(n => n.nodeValue || '') - .join(' ') || ''); - if (el.shadowRoot) text += ' ' + collectVisibleText(el.shadowRoot); - } - - return text; + return el.value || ''; } - function messageAppearsInTranscript() { - seen.clear(); - return normalize(collectVisibleText(document)).includes(normalize(msg)); + const input = findInput(); + if (!input) { + console.warn('[OpenClaw] Could not find chat input for bootstrap'); + return 'no-input'; } - function inputWasAccepted(input) { - const value = normalize(getInputValue(input)); - return value.length === 0 || value !== normalize(msg); - } - - function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); + input.focus(); + setNativeValue(input, msg); + try { + input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: msg })); + } catch { + input.dispatchEvent(new Event('input', { bubbles: true })); } + input.dispatchEvent(new Event('change', { bubbles: true })); - async function confirmAccepted(input) { - for (let i = 0; i < pollCount; i++) { - if (inputWasAccepted(input) || messageAppearsInTranscript()) { - return true; - } - await sleep(pollDelayMs); - } - - return false; + if (getRenderedValue(input) !== msg) { + console.warn('[OpenClaw] Bootstrap message did not render in composer'); + return 'not-rendered'; } - async function tryInjectOnce() { - seen.clear(); - const input = findInput(document); - if (!input) { - console.warn('[OpenClaw] Could not find chat input for bootstrap'); - return 'no-input'; - } - - input.focus(); - setNativeValue(input, msg); - input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: msg })); - input.dispatchEvent(new Event('change', { bubbles: true })); - - const form = findForm(input); - if (form?.requestSubmit) { + const form = input.closest ? input.closest('form') : null; + if (form && typeof form.requestSubmit === 'function') { + try { form.requestSubmit(); console.log('[OpenClaw] Bootstrap message submitted via composer form'); - return await confirmAccepted(input) ? 'sent' : 'sent-unverified'; - } - - const btn = findSendButton(input); - if (btn) { - btn.click(); - console.log('[OpenClaw] Bootstrap message submitted via composer send button'); - return await confirmAccepted(input) ? 'sent' : 'sent-unverified'; - } - - input.dispatchEvent(new KeyboardEvent('keydown', { - key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true - })); - - if (await confirmAccepted(input)) { - console.log('[OpenClaw] Bootstrap message submitted via Enter key'); return 'sent'; - } - - console.warn('[OpenClaw] Bootstrap message not accepted by composer'); - return 'no-send-button'; + } catch { } } - const inputCount = document.querySelectorAll('input,textarea,button,[contenteditable="true"],[role="textbox"]').length; - console.log('[OpenClaw] Bootstrap probe controls=' + inputCount); - - let lastStatus = 'no-input'; - for (const delay of attempts) { - if (delay > 0) await sleep(delay); - lastStatus = await tryInjectOnce(); - if (lastStatus !== 'no-input') return lastStatus; + const button = findSendButton(); + if (!button) { + console.warn('[OpenClaw] Bootstrap message rendered, but send button was not found'); + return 'rendered'; } - return lastStatus; + button.click(); + console.log('[OpenClaw] Bootstrap message submitted via chat send button'); + return 'sent'; })(); """; } @@ -339,10 +220,11 @@ public static async Task InjectAsync( var js = BuildInjectionScript(Message); var result = await executor(js).ConfigureAwait(true); var status = TryParseScriptResult(result); - if (string.Equals(status, "sent", StringComparison.Ordinal)) + if (string.Equals(status, "sent", StringComparison.Ordinal) || + string.Equals(status, "rendered", StringComparison.Ordinal)) { MarkInjected(settings); - Logger.Info("[BootstrapMessageInjector] Bootstrap message injection sent"); + Logger.Info("[BootstrapMessageInjector] Bootstrap message injection rendered"); return true; } diff --git a/src/OpenClaw.Tray.WinUI/Services/StartupSetupState.cs b/src/OpenClaw.Tray.WinUI/Services/StartupSetupState.cs index 3fc2ca100..933f59f2e 100644 --- a/src/OpenClaw.Tray.WinUI/Services/StartupSetupState.cs +++ b/src/OpenClaw.Tray.WinUI/Services/StartupSetupState.cs @@ -5,7 +5,7 @@ namespace OpenClawTray.Services; internal static class StartupSetupState { public static bool HasStoredNodeDeviceToken(string dataPath) => - DeviceIdentity.HasStoredDeviceToken(dataPath, NullLogger.Instance); + DeviceIdentity.HasStoredDeviceTokenForRole(dataPath, "node", NullLogger.Instance); public static bool CanStartNodeGateway(SettingsManager settings, string dataPath) { diff --git a/src/OpenClaw.Tray.WinUI/Windows/ChatWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/ChatWindow.xaml.cs index a69319c6d..3f9284ef9 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/ChatWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/ChatWindow.xaml.cs @@ -247,8 +247,15 @@ CoreWebView2WebErrorStatus.ServerUnreachable or ErrorPanel.Visibility = Visibility.Collapsed; WebView.Visibility = Visibility.Visible; RequestChatInputFocus(); - OpenClawTray.Services.BootstrapMessageInjector.ScriptExecutor exec = script => WebView.CoreWebView2.ExecuteScriptAsync(script).AsTask(); - _ = OpenClawTray.Services.BootstrapMessageInjector.InjectAsync(exec, ((App)Microsoft.UI.Xaml.Application.Current).Settings, initialDelayMs: 500); + try + { + OpenClawTray.Services.BootstrapMessageInjector.ScriptExecutor exec = script => WebView.CoreWebView2.ExecuteScriptAsync(script).AsTask(); + _ = OpenClawTray.Services.BootstrapMessageInjector.InjectAsync(exec, ((App)Microsoft.UI.Xaml.Application.Current).Settings, initialDelayMs: 500); + } + catch (Exception ex) + { + Logger.Warn($"[ChatWindow] Bootstrap injection dispatch failed: {ex.Message}"); + } } }; diff --git a/tests/OpenClaw.Tray.Tests/BootstrapMessageInjectorTests.cs b/tests/OpenClaw.Tray.Tests/BootstrapMessageInjectorTests.cs index 292d45dab..e5b6c99f0 100644 --- a/tests/OpenClaw.Tray.Tests/BootstrapMessageInjectorTests.cs +++ b/tests/OpenClaw.Tray.Tests/BootstrapMessageInjectorTests.cs @@ -88,10 +88,23 @@ public async Task InjectAsync_FlipsGate_OnSuccessfulExecution() Assert.Contains("BOOTSTRAP.md", capturedScript!); } + [Fact] + public async Task InjectAsync_FlipsGate_WhenMessageRendered() + { + var settings = new SettingsManager(_isolatedDir); + BootstrapMessageInjector.ScriptExecutor executor = _ => Task.FromResult("\"rendered\""); + + var result = await BootstrapMessageInjector.InjectAsync(executor, settings, initialDelayMs: 0); + + Assert.True(result); + Assert.True(settings.HasInjectedFirstRunBootstrap); + } + [Theory] [InlineData("\"sent-unverified\"")] [InlineData("\"no-input\"")] [InlineData("\"no-send-button\"")] + [InlineData("\"not-rendered\"")] [InlineData("\"unknown\"")] public async Task InjectAsync_DoesNotFlipGate_WhenSendIsUnverified(string scriptResult) { @@ -137,4 +150,16 @@ public void BuildInjectionScript_EncodesMessageSafely() // JSON-escaped form embeds the encoded payload (escaped quote). Assert.Contains("\\\"", script); } + + [Fact] + public void BuildInjectionScript_UsesBroadChatComposerDiscovery() + { + var script = BootstrapMessageInjector.BuildInjectionScript("hello"); + + Assert.Contains("allCandidateElements", script); + Assert.Contains("shadowRoot", script); + Assert.Contains("textarea:not([disabled])", script); + Assert.Contains("[contenteditable=\"true\"]", script); + Assert.Contains("button[aria-label*=\"Send\" i]", script); + } } diff --git a/tests/OpenClaw.Tray.Tests/StartupSetupStateTests.cs b/tests/OpenClaw.Tray.Tests/StartupSetupStateTests.cs index 9eb91425a..b1167f8e2 100644 --- a/tests/OpenClaw.Tray.Tests/StartupSetupStateTests.cs +++ b/tests/OpenClaw.Tray.Tests/StartupSetupStateTests.cs @@ -9,13 +9,24 @@ public class StartupSetupStateTests public void RequiresSetup_ReturnsFalse_WhenNodeHasStoredDeviceToken() { using var temp = TempSettings.Create(); - StoreDeviceToken(temp.Path); + StoreNodeDeviceToken(temp.Path); var settings = new SettingsManager(temp.Path) { EnableNodeMode = true }; Assert.False(StartupSetupState.RequiresSetup(settings, temp.Path)); Assert.True(StartupSetupState.CanStartNodeGateway(settings, temp.Path)); } + [Fact] + public void RequiresSetup_ReturnsTrue_WhenOnlyOperatorTokenExistsForNodeMode() + { + using var temp = TempSettings.Create(); + StoreDeviceToken(temp.Path); + var settings = new SettingsManager(temp.Path) { EnableNodeMode = true }; + + Assert.True(StartupSetupState.RequiresSetup(settings, temp.Path)); + Assert.False(StartupSetupState.CanStartNodeGateway(settings, temp.Path)); + } + [Fact] public void RequiresSetup_ReturnsFalse_WhenMcpOnlyModeIsEnabled() { @@ -42,6 +53,13 @@ private static void StoreDeviceToken(string dataPath) identity.StoreDeviceToken("stored-device-token"); } + private static void StoreNodeDeviceToken(string dataPath) + { + var identity = new DeviceIdentity(dataPath); + identity.Initialize(); + identity.StoreDeviceTokenForRole("node", "stored-node-token"); + } + private sealed class TempSettings : IDisposable { public string Path { get; } From 80b98fa9c993407278b7218f476d58a3dbbc212b Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 11 May 2026 11:32:04 -0700 Subject: [PATCH 02/16] fix(connection): suppress manager-owned node connector when local NodeService owns identity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 24 +++++- .../Connection/GatewayConnectionManager.cs | 22 +++++- .../GatewayConnectionManagerTests.cs | 78 ++++++++++++++++++- 3 files changed, 119 insertions(+), 5 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index dd74d8cd4..85bf8c2c9 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -482,7 +482,8 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) nodeConnector: nodeConnector, isNodeEnabled: ShouldInitializeNodeService, diagnostics: diagnostics, - tunnelManager: tunnelManager); + tunnelManager: tunnelManager, + shouldStartNodeConnection: ShouldInitializeNodeService); _connectionManager.OperatorClientChanged += OnOperatorClientChanged; _connectionManager.StateChanged += OnManagerStateChanged; @@ -2187,6 +2188,27 @@ private bool ShouldInitializeNodeService() return _settings?.EnableNodeMode == true || _settings?.EnableMcpServer == true; } + private bool ShouldInitializeNodeService(GatewayRecord activeGateway, string managerIdentityPath) + { + if (!ShouldInitializeNodeService()) return false; + + if (LocalNodeServiceOwnsIdentityFor(activeGateway)) + { + Logger.Info("[ConnMgr] Suppressing manager-owned NodeConnector because local NodeService owns the active local gateway identity"); + return false; + } + + return true; + } + + private bool LocalNodeServiceOwnsIdentityFor(GatewayRecord activeGateway) + { + if (!activeGateway.IsLocal || _settings == null) return false; + if (!StartupSetupState.HasStoredNodeDeviceToken(IdentityDataPath)) return false; + + return EnsureNodeServiceForLocalGatewaySetup(_settings) != null; + } + private void OnNodeStatusChanged(object? sender, ConnectionStatus status) { Logger.Info($"Node status: {status}"); diff --git a/src/OpenClaw.Tray.WinUI/Services/Connection/GatewayConnectionManager.cs b/src/OpenClaw.Tray.WinUI/Services/Connection/GatewayConnectionManager.cs index ac572fb75..da37f3efb 100644 --- a/src/OpenClaw.Tray.WinUI/Services/Connection/GatewayConnectionManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/Connection/GatewayConnectionManager.cs @@ -19,6 +19,7 @@ public sealed class GatewayConnectionManager : IGatewayConnectionManager private readonly INodeConnector? _nodeConnector; private readonly ISshTunnelManager? _tunnelManager; private readonly Func? _isNodeEnabled; + private readonly Func? _shouldStartNodeConnection; private readonly SemaphoreSlim _transitionSemaphore = new(1, 1); private long _generation; @@ -44,7 +45,8 @@ public GatewayConnectionManager( INodeConnector? nodeConnector = null, Func? isNodeEnabled = null, ConnectionDiagnostics? diagnostics = null, - ISshTunnelManager? tunnelManager = null) + ISshTunnelManager? tunnelManager = null, + Func? shouldStartNodeConnection = null) { _credentialResolver = credentialResolver ?? throw new ArgumentNullException(nameof(credentialResolver)); _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); @@ -54,6 +56,7 @@ public GatewayConnectionManager( _nodeConnector = nodeConnector; _tunnelManager = tunnelManager; _isNodeEnabled = isNodeEnabled; + _shouldStartNodeConnection = shouldStartNodeConnection; _diagnostics = diagnostics ?? new ConnectionDiagnostics(clock: clock); _diagnostics.EventRecorded += (_, e) => DiagnosticEvent?.Invoke(this, e); @@ -467,7 +470,7 @@ private async Task HandleHandshakeSucceededAsync(long gen) } // Start node connection outside the semaphore to avoid deadlocks - if (_nodeConnector != null && (_isNodeEnabled?.Invoke() ?? false)) + if (_nodeConnector != null && ShouldStartNodeConnection()) { await StartNodeConnectionAsync(); } @@ -528,6 +531,21 @@ private async Task HandlePairingRequiredAsync(string? requestId, long gen) // ─── Node Connection ─── + private bool ShouldStartNodeConnection() + { + if (_activeGatewayRecordId == null || _activeIdentityPath == null) + return _isNodeEnabled?.Invoke() ?? false; + + var record = _registry.GetById(_activeGatewayRecordId); + if (record == null) + return false; + + if (_shouldStartNodeConnection != null) + return _shouldStartNodeConnection(record, _activeIdentityPath); + + return _isNodeEnabled?.Invoke() ?? false; + } + private async Task StartNodeConnectionAsync() { if (_nodeConnector == null || _activeGatewayRecordId == null || _activeIdentityPath == null) return; diff --git a/tests/OpenClaw.Tray.Tests/Connection/GatewayConnectionManagerTests.cs b/tests/OpenClaw.Tray.Tests/Connection/GatewayConnectionManagerTests.cs index f9d5724e5..fb6a372e8 100644 --- a/tests/OpenClaw.Tray.Tests/Connection/GatewayConnectionManagerTests.cs +++ b/tests/OpenClaw.Tray.Tests/Connection/GatewayConnectionManagerTests.cs @@ -158,14 +158,61 @@ public void Diagnostics_IsAccessible() Assert.Equal(0, _manager.Diagnostics.Count); } + [Fact] + public async Task HandshakeSucceeded_SuppressesManagerNodeConnector_WhenLocalNodeServiceOwnsIdentity() + { + SetupGateway("gw-local", "ws://localhost:18789", isLocal: true); + _resolver.OperatorCredential = new GatewayCredential("op-tok", false, "test"); + _resolver.NodeCredential = new GatewayCredential("node-tok", false, "test"); + var nodeConnector = new CountingNodeConnector(); + using var manager = new GatewayConnectionManager( + _resolver, _factory, _registry, NullLogger.Instance, + nodeConnector: nodeConnector, + shouldStartNodeConnection: (record, _) => !record.IsLocal); + + await manager.ConnectAsync("gw-local"); + await InvokeHandshakeSucceededAsync(manager); + + Assert.Equal(0, nodeConnector.ConnectCount); + } + + [Fact] + public async Task HandshakeSucceeded_StartsManagerNodeConnector_WhenNoLocalNodeServiceOwnsIdentity() + { + SetupGateway("gw-remote", "wss://remote.example", isLocal: false); + _resolver.OperatorCredential = new GatewayCredential("op-tok", false, "test"); + _resolver.NodeCredential = new GatewayCredential("node-tok", false, "test"); + var nodeConnector = new CountingNodeConnector(); + using var manager = new GatewayConnectionManager( + _resolver, _factory, _registry, NullLogger.Instance, + nodeConnector: nodeConnector, + shouldStartNodeConnection: (record, _) => !record.IsLocal); + + await manager.ConnectAsync("gw-remote"); + await InvokeHandshakeSucceededAsync(manager); + + Assert.Equal(1, nodeConnector.ConnectCount); + Assert.Equal("wss://remote.example", nodeConnector.LastGatewayUrl); + } + // ─── Helpers ─── - private void SetupGateway(string id, string url) + private void SetupGateway(string id, string url, bool isLocal = false) { - _registry.AddOrUpdate(new GatewayRecord { Id = id, Url = url }); + _registry.AddOrUpdate(new GatewayRecord { Id = id, Url = url, IsLocal = isLocal }); _registry.SetActive(id); } + private static async Task InvokeHandshakeSucceededAsync(GatewayConnectionManager manager) + { + var method = typeof(GatewayConnectionManager).GetMethod( + "HandleHandshakeSucceededAsync", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.NotNull(method); + var task = (Task)method!.Invoke(manager, [1L])!; + await task; + } + // ─── Mocks ─── private sealed class MockCredentialResolver : ICredentialResolver @@ -218,4 +265,31 @@ private sealed class MockGatewayClient : OpenClawGatewayClient public MockGatewayClient(string url) : base(url, "mock-token", NullLogger.Instance) { } } + + private sealed class CountingNodeConnector : INodeConnector + { + public int ConnectCount { get; private set; } + public string? LastGatewayUrl { get; private set; } + public bool IsConnected => ConnectCount > 0; + public PairingStatus PairingStatus { get; private set; } = PairingStatus.Unknown; + public string? NodeDeviceId => "test-node"; + public NodeConnectionMode Mode => IsConnected ? NodeConnectionMode.Gateway : NodeConnectionMode.Disabled; + +#pragma warning disable CS0067 // Events required by interface but not fired in tests + public event EventHandler? StatusChanged; + public event EventHandler? PairingStatusChanged; +#pragma warning restore CS0067 + + public Task ConnectAsync(string gatewayUrl, GatewayCredential credential, string identityPath, bool useV2Signature = false) + { + ConnectCount++; + LastGatewayUrl = gatewayUrl; + PairingStatus = PairingStatus.Paired; + return Task.CompletedTask; + } + + public Task DisconnectAsync() => Task.CompletedTask; + + public void Dispose() { } + } } From 0fe6deca31a00e6e32bff6bcd0013a364bc42e70 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 11 May 2026 11:32:42 -0700 Subject: [PATCH 03/16] fix(onboarding): add in-flight guard and post-delay gate re-check to bootstrap injector Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/BootstrapMessageInjector.cs | 17 +++++++++++++++ .../BootstrapMessageInjectorTests.cs | 21 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/OpenClaw.Tray.WinUI/Services/BootstrapMessageInjector.cs b/src/OpenClaw.Tray.WinUI/Services/BootstrapMessageInjector.cs index 7679f870c..0e4dad228 100644 --- a/src/OpenClaw.Tray.WinUI/Services/BootstrapMessageInjector.cs +++ b/src/OpenClaw.Tray.WinUI/Services/BootstrapMessageInjector.cs @@ -25,6 +25,8 @@ namespace OpenClawTray.Services; /// public static class BootstrapMessageInjector { + private static int s_inFlight; + /// /// Delegate matching CoreWebView2.ExecuteScriptAsync(string). /// Returns the JSON-serialized result of the script (unused here). @@ -209,6 +211,11 @@ public static async Task InjectAsync( if (settings is null) return false; if (!ShouldInject(settings)) return false; if (executor is null) return false; + if (Interlocked.CompareExchange(ref s_inFlight, 1, 0) != 0) + { + Logger.Info("[BootstrapMessageInjector] Bootstrap injection skipped because another injection is in flight"); + return false; + } try { @@ -217,6 +224,12 @@ public static async Task InjectAsync( await Task.Delay(initialDelayMs, cancellationToken).ConfigureAwait(true); } + if (!ShouldInject(settings)) + { + Logger.Info("[BootstrapMessageInjector] Bootstrap injection skipped because gate was consumed during initial delay"); + return false; + } + var js = BuildInjectionScript(Message); var result = await executor(js).ConfigureAwait(true); var status = TryParseScriptResult(result); @@ -240,6 +253,10 @@ public static async Task InjectAsync( Logger.Warn($"[BootstrapMessageInjector] Bootstrap injection failed: {ex.Message}"); return false; } + finally + { + Interlocked.Exchange(ref s_inFlight, 0); + } } private static string? TryParseScriptResult(string? result) diff --git a/tests/OpenClaw.Tray.Tests/BootstrapMessageInjectorTests.cs b/tests/OpenClaw.Tray.Tests/BootstrapMessageInjectorTests.cs index e5b6c99f0..385a872c1 100644 --- a/tests/OpenClaw.Tray.Tests/BootstrapMessageInjectorTests.cs +++ b/tests/OpenClaw.Tray.Tests/BootstrapMessageInjectorTests.cs @@ -138,6 +138,27 @@ public async Task InjectAsync_DoesNotFireTwice() Assert.Equal(1, callCount); } + [Fact] + public async Task InjectAsync_ConcurrentCalls_ExecuteScriptOnce() + { + var settings = new SettingsManager(_isolatedDir); + int callCount = 0; + BootstrapMessageInjector.ScriptExecutor executor = async _ => + { + Interlocked.Increment(ref callCount); + await Task.Delay(50); + return "\"sent\""; + }; + + var results = await Task.WhenAll( + BootstrapMessageInjector.InjectAsync(executor, settings, initialDelayMs: 25), + BootstrapMessageInjector.InjectAsync(executor, settings, initialDelayMs: 25)); + + Assert.Single(results, static r => r); + Assert.Equal(1, callCount); + Assert.True(settings.HasInjectedFirstRunBootstrap); + } + [Fact] public void BuildInjectionScript_EncodesMessageSafely() { From cc1e8f09c05f5a05acae14748864a09ccfe4bc19 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 11 May 2026 11:33:03 -0700 Subject: [PATCH 04/16] fix(onboarding): distinguish sent vs rendered status in bootstrap injector Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/BootstrapMessageInjector.cs | 11 ++++++++--- .../BootstrapMessageInjectorTests.cs | 6 +++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Services/BootstrapMessageInjector.cs b/src/OpenClaw.Tray.WinUI/Services/BootstrapMessageInjector.cs index 0e4dad228..254479f2f 100644 --- a/src/OpenClaw.Tray.WinUI/Services/BootstrapMessageInjector.cs +++ b/src/OpenClaw.Tray.WinUI/Services/BootstrapMessageInjector.cs @@ -233,14 +233,19 @@ public static async Task InjectAsync( var js = BuildInjectionScript(Message); var result = await executor(js).ConfigureAwait(true); var status = TryParseScriptResult(result); - if (string.Equals(status, "sent", StringComparison.Ordinal) || - string.Equals(status, "rendered", StringComparison.Ordinal)) + if (string.Equals(status, "sent", StringComparison.Ordinal)) { MarkInjected(settings); - Logger.Info("[BootstrapMessageInjector] Bootstrap message injection rendered"); + Logger.Info("[BootstrapMessageInjector] Bootstrap message injection sent"); return true; } + if (string.Equals(status, "rendered", StringComparison.Ordinal)) + { + Logger.Warn("[BootstrapMessageInjector] Bootstrap message rendered in composer but was not confirmed sent; gate remains open"); + return false; + } + Logger.Warn($"[BootstrapMessageInjector] Bootstrap injection did not send; status={status ?? result ?? ""}"); return false; } diff --git a/tests/OpenClaw.Tray.Tests/BootstrapMessageInjectorTests.cs b/tests/OpenClaw.Tray.Tests/BootstrapMessageInjectorTests.cs index 385a872c1..6d788a22e 100644 --- a/tests/OpenClaw.Tray.Tests/BootstrapMessageInjectorTests.cs +++ b/tests/OpenClaw.Tray.Tests/BootstrapMessageInjectorTests.cs @@ -89,15 +89,15 @@ public async Task InjectAsync_FlipsGate_OnSuccessfulExecution() } [Fact] - public async Task InjectAsync_FlipsGate_WhenMessageRendered() + public async Task InjectAsync_DoesNotFlipGate_WhenMessageRendered() { var settings = new SettingsManager(_isolatedDir); BootstrapMessageInjector.ScriptExecutor executor = _ => Task.FromResult("\"rendered\""); var result = await BootstrapMessageInjector.InjectAsync(executor, settings, initialDelayMs: 0); - Assert.True(result); - Assert.True(settings.HasInjectedFirstRunBootstrap); + Assert.False(result); + Assert.False(settings.HasInjectedFirstRunBootstrap); } [Theory] From aa820d3b631845d3f86a4ac7d63746f430f3c6fe Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 11 May 2026 11:33:34 -0700 Subject: [PATCH 05/16] docs(squad): summarize onboarding double-init fixes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../expertise/onboarding-chat-bootstrap.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.squad/agents/aaron/expertise/onboarding-chat-bootstrap.md b/.squad/agents/aaron/expertise/onboarding-chat-bootstrap.md index 993a2536e..401292b7a 100644 --- a/.squad/agents/aaron/expertise/onboarding-chat-bootstrap.md +++ b/.squad/agents/aaron/expertise/onboarding-chat-bootstrap.md @@ -19,3 +19,21 @@ This fix deliberately does not depend on one exact live chat composer DOM. The p ## Matchers and timing `BootstrapMessageInjector.BuildInjectionScript(...)` encodes the message with `JsonSerializer.Serialize` so BOOTSTRAP text cannot break out of JS string context. Discovery walks `document` plus open `shadowRoot`s, filters to visible/enabled/editable elements, sets native input values (or `textContent` for contenteditable), dispatches `input` and `change`, and verifies the composer contains the message before consuming `SettingsManager.HasInjectedFirstRunBootstrap`. `ChatPage` and `ChatWindow` call injection after successful navigation with a 500ms delay; the legacy onboarding overlay still uses the service default 3000ms delay. The one-shot gate is consumed only for `sent` or `rendered`, so a missing input or failed render retries on the next chat visit instead of permanently losing the hatching prompt. + +## 2026-05-11 double-init investigation + +The post-wizard handoff guard held in the live repro: `OnWizardComplete` logged the Hub chat launch once. The duplicate notification/state churn came after handoff from two Windows-node connection owners. Local setup had already created and paired the legacy `NodeService` identity under `%APPDATA%\OpenClawTray`; after onboarding completed, `GatewayConnectionManager.HandleHandshakeSucceededAsync()` started its `NodeConnector` and used the per-gateway/operator identity path as a node identity, causing a role-upgrade pairing and a second Windows node entry in gateway `paired.json`. + +Do not assume post-wizard chat weirdness means `ShowHubChatAfterWizardClose()` fired twice. First check for two node identities in gateway `paired.json`: the canonical tray identity from `%APPDATA%\OpenClawTray\device-key-ed25519.json`, plus a per-gateway/operator identity being connected as `role=node`. If both exist, the immediate fix should be single node ownership: suppress the connection-manager `NodeConnector` while local setup/legacy `NodeService` owns the tray node, or make both paths share the same canonical node identity. + +Bootstrap has its own race window: `InjectAsync()` checks `HasInjectedFirstRunBootstrap` before `initialDelayMs`, then executes later without re-checking. Multiple WebView navigation completions can therefore schedule overlapping injector tasks that all passed the gate before the first one persisted `HasInjectedFirstRunBootstrap`. Re-check the gate after the delay and add an in-flight guard before treating any future double-send or text-in-composer symptom as a gateway pairing bug. + +## Bug A PR archeology + +Bug A's double Windows-node ownership is not ancient. The local WSL setup side came from PR #274 (`581f78d276e1e6569f6385a9a991b60467c4c6e5`): `CreateLocalGatewaySetupEngine()` eagerly creates `NodeService`, and `LocalGatewaySetupEngineFactory` wraps it in `NodeServiceWindowsNodeConnector` to pair the canonical tray node identity. The second owner came later from PR #304 (`e3c6504aaf27bfe5862ed2029865086613d6f866`): `GatewayConnectionManager.HandleHandshakeSucceededAsync()` starts `StartNodeConnectionAsync()` via `NodeConnector` when `ShouldInitializeNodeService()` returns true. Treat WSL local gateway symptoms after setup as a single-ownership problem first: once setup has paired the canonical `NodeService`, the connection manager must not also start a per-gateway/operator-identity node connector for that same local gateway. + +## 2026-05-11 fix ABC notes + +The local WSL gateway has two possible Windows-node startup paths: the setup-owned `NodeService` using the canonical `%APPDATA%\OpenClawTray` identity, and PR #304's manager-owned `NodeConnector` using the per-gateway/operator identity. For local `GatewayRecord.IsLocal` entries, if `StartupSetupState.HasStoredNodeDeviceToken(IdentityDataPath)` is true and `EnsureNodeServiceForLocalGatewaySetup(...)` can provide the local node service, suppress the manager-owned connector; keep the manager connector for remote/no-local-setup gateways. + +Bootstrap injection must treat `HasInjectedFirstRunBootstrap` as both a persistent gate and an in-process critical section. Check the gate before scheduling, acquire an in-flight guard before the delay, re-check the gate immediately after the delay, and only consume the gate when script status is `sent`; `rendered` is diagnostic only and should retry later. \ No newline at end of file From c81517ad948451aabf8d65455344df80f1ed755e Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 11 May 2026 11:33:43 -0700 Subject: [PATCH 06/16] docs(squad): record fix abc summary Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../decisions/inbox/aaron-fix-abc-summary.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .squad/decisions/inbox/aaron-fix-abc-summary.md diff --git a/.squad/decisions/inbox/aaron-fix-abc-summary.md b/.squad/decisions/inbox/aaron-fix-abc-summary.md new file mode 100644 index 000000000..fbcb02a0c --- /dev/null +++ b/.squad/decisions/inbox/aaron-fix-abc-summary.md @@ -0,0 +1,23 @@ +# Aaron fix ABC summary + +Date: 2026-05-11T11:25:00-07:00 +Requested by: Mike Harsh +Branch: fix/bootstrap-injector-properly +PR: #312 + +## What changed + +- Bug A: `GatewayConnectionManager` now supports an active-gateway-aware node-start predicate. `App.ShouldInitializeNodeService(GatewayRecord, string)` suppresses the PR #304 manager-owned `NodeConnector` when the active gateway is local and the canonical local `NodeService` already owns a stored node token under `IdentityDataPath`. The remote/no-local-setup path is preserved. +- Bug B: `BootstrapMessageInjector.InjectAsync(...)` now has an in-process in-flight guard and re-checks `HasInjectedFirstRunBootstrap` after the initial delay, before executing JavaScript. +- Bug C: bootstrap status handling now distinguishes `sent` from `rendered`; only `sent` consumes `HasInjectedFirstRunBootstrap`. `rendered` logs that the composer was filled but send was not confirmed, leaving the gate open for retry. + +## Tests added/updated + +- Manager-owned connector is suppressed for a local gateway when the local node owner is active. +- Manager-owned connector still starts for a remote gateway. +- Concurrent bootstrap injections execute JavaScript exactly once. +- `rendered` does not consume the bootstrap one-shot gate. + +## Manual smoke still required + +Mike must complete the real WSL wizard path and verify: A) tray opens Hub Chat after wizard completion, B) Chat connects to the gateway, C) hatching prompt renders and submits with an assistant reply, D) no second pairing notification appears after Chat opens, and E) `wsl -d OpenClawGateway -- cat ~/.openclaw/devices/paired.json` contains exactly one Windows-node entry. \ No newline at end of file From 05923fe4d67747aa9caac07cab008a727ad9548b Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 11 May 2026 13:33:10 -0700 Subject: [PATCH 07/16] fix(chat): defer ChatPage navigation until gateway chat surface is serving Wait for the operator hello-ok boundary before allowing the ChatPage WebView to navigate, then probe the tokenized chat URL until the HTTP surface returns success. Keep the Chat tab open in a bounded waiting/retry state so post-wizard auto-launch still opens Chat without exposing a transient 404. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml | 18 ++- .../Pages/ChatPage.xaml.cs | 129 +++++++++++++++++- .../Connection/ChatNavigationReadiness.cs | 43 ++++++ .../GatewayConnectionManagerTests.cs | 19 +++ .../OpenClaw.Tray.Tests.csproj | 1 + 5 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Services/Connection/ChatNavigationReadiness.cs diff --git a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml index c95e88bfa..7f4400374 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml @@ -56,7 +56,23 @@ + HorizontalAlignment="Center" VerticalAlignment="Center"/> + + + + + +