From 650c6e4da8b96d404f191949f9f0ceb9348f317c Mon Sep 17 00:00:00 2001 From: Pedro Larroy Date: Thu, 8 Oct 2026 10:39:35 -0700 Subject: [PATCH 1/2] fix(chat): normalize composer line breaks sent to the gateway The multiline composer (Enter sends, Shift+Enter newline) stores WinUI TextBox line breaks as carriage returns. SendAsync forwarded that raw format, so multi-line messages left the tray as lone CR bytes. Normalize CR and CRLF to LF at send time, keeping other Unicode line separators in user content untouched. Add a controller unit test and a real-process keyboard proof that Enter sends and Shift+Enter inserts a newline without sending. --- .../Chat/ChatComposerController.cs | 2 +- .../ChatComposerControllerTests.cs | 11 +++ .../WorkspaceWindowProofTests.cs | 74 +++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatComposerController.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatComposerController.cs index d2940243fd..089aa189a6 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatComposerController.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatComposerController.cs @@ -164,7 +164,7 @@ public async Task SendAsync() var thread = inputs.CurrentThread; var draft = _vm.Draft; var attachments = _vm.PendingAttachments; - var message = draft.Trim(); + var message = draft.Trim().Replace("\r\n", "\n").Replace('\r', '\n'); if ((message.Length == 0 && attachments.Count == 0) || _vm.SlashDisplay.IsLoading || inputs.ConnectionState != "connected") diff --git a/tests/OpenClaw.Tray.Tests/ChatComposerControllerTests.cs b/tests/OpenClaw.Tray.Tests/ChatComposerControllerTests.cs index a9e9ddccae..ab0511bca4 100644 --- a/tests/OpenClaw.Tray.Tests/ChatComposerControllerTests.cs +++ b/tests/OpenClaw.Tray.Tests/ChatComposerControllerTests.cs @@ -98,6 +98,17 @@ public async Task SendAsync_OrdinaryMessage_CallsProviderExactlyOnceAndClearsDra Assert.Equal(string.Empty, vm.Draft); } + [Fact] + public async Task SendAsync_TextBoxLineBreaks_AreSentAsLineFeeds() + { + var (vm, controller, port, _) = MakeController(); + vm.SetDraft("line one\rline two\r\nline three"); + + Assert.True(await controller.SendAsync()); + + Assert.Equal("line one\nline two\nline three", port.LastSendMessageCall!.Value.Message); + } + [Fact] public async Task SendAsync_ActiveTurn_AcceptedFollowUpClearsComposerAndAllowsAnotherDraft() { diff --git a/tests/OpenClaw.Tray.UITests/WorkspaceWindowProofTests.cs b/tests/OpenClaw.Tray.UITests/WorkspaceWindowProofTests.cs index d92895ea5e..4611af8b55 100644 --- a/tests/OpenClaw.Tray.UITests/WorkspaceWindowProofTests.cs +++ b/tests/OpenClaw.Tray.UITests/WorkspaceWindowProofTests.cs @@ -735,6 +735,80 @@ await WaitUntilAsync(() => await WaitUntilAsync(() => IsVisible(workspaceElement, "WorkspaceOwner")); } + [Fact] + public async Task ChatComposer_EnterSendsAndShiftEnterInsertsNewline() + { + using var app = new AccessibilityAppFixture(initializeAxe: false, syntheticData: true, initialRoute: null); + await app.NavigateAsync("chat", "ChatPage", "ChatComposerInput"); + var root = AutomationElement.FromHandle(app.HubWindowHandle); + string Value() => ((ValuePattern)Find(root, "ChatComposerInput") + .GetCurrentPattern(ValuePattern.Pattern)).Current.Value; + void SetValue(string value) => ((ValuePattern)Find(root, "ChatComposerInput") + .GetCurrentPattern(ValuePattern.Pattern)).SetValue(value); + + // SendKeys targets the foreground window, so transient foreground contention + // can drop or scramble a batch. Each attempt is one full real-keyboard pass + // from a clean state; a deterministic product defect fails every attempt the + // same way and still fails the test. + for (var attempt = 1; ; attempt++) + { + SetValue(string.Empty); + var failure = await AttemptAsync(attempt); + if (failure is null) + return; + if (attempt == 4) + Assert.Fail($"Real-keyboard composer pass failed after {attempt} attempts. Last failure: {failure}"); + await Task.Delay(500); + } + + async Task AttemptAsync(int attempt) + { + try + { + // A background window can lose foreground permanently (minimized or + // stolen), which focus polling alone cannot recover from. + var window = (WindowPattern)root.GetCurrentPattern(WindowPattern.Pattern); + if (window.Current.WindowVisualState != WindowVisualState.Normal) + window.SetWindowVisualState(WindowVisualState.Normal); + await FocusForKeyboardAsync(app, root, "ChatComposerInput"); + System.Windows.Forms.SendKeys.SendWait("first line"); + if (!await WaitBrieflyAsync(() => Value() == "first line")) + return $"attempt {attempt}: typing did not land; value was '{Escape(Value())}'"; + + await FocusForKeyboardAsync(app, root, "ChatComposerInput"); + System.Windows.Forms.SendKeys.SendWait("+{ENTER}"); + System.Windows.Forms.SendKeys.SendWait("second line"); + if (!await WaitBrieflyAsync( + () => Value().ReplaceLineEndings("\n") == "first line\nsecond line")) + return $"attempt {attempt}: Shift+Enter did not produce two lines; value was '{Escape(Value())}'"; + + await FocusForKeyboardAsync(app, root, "ChatComposerInput"); + System.Windows.Forms.SendKeys.SendWait("{ENTER}"); + if (!await WaitBrieflyAsync(() => Value().Length == 0)) + return $"attempt {attempt}: Enter did not clear the composer; value was '{Escape(Value())}'"; + return null; + } + catch (Exception ex) + { + return $"attempt {attempt}: {ex.GetType().Name}: {ex.Message}"; + } + } + } + + private static string Escape(string value) => + value.Replace("\r", "{CR}").Replace("\n", "{LF}"); + + private static async Task WaitBrieflyAsync(Func predicate) + { + var timeout = Stopwatch.StartNew(); + while (timeout.Elapsed < TimeSpan.FromSeconds(2)) + { + if (predicate()) return true; + await Task.Delay(100); + } + return predicate(); + } + private static bool IsVisible(AutomationElement root, string id) => FindOrNull(root, id) is { } element && !element.Current.IsOffscreen && element.Current.BoundingRectangle.Width > 0; From 2750135c3ef72cbe8bdc4cce465f42d786449e95 Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Wed, 14 Oct 2026 18:23:31 -0700 Subject: [PATCH 2/2] test(chat): use gated native keyboard proof Keep the line-ending regression at the send boundary and rely on the existing opt-in NativeKeyboard coverage for foreground key injection. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7c0b5c5-d1fc-468d-a9b1-e58c402f13e8 --- .../WorkspaceWindowProofTests.cs | 74 ------------------- 1 file changed, 74 deletions(-) diff --git a/tests/OpenClaw.Tray.UITests/WorkspaceWindowProofTests.cs b/tests/OpenClaw.Tray.UITests/WorkspaceWindowProofTests.cs index 4611af8b55..d92895ea5e 100644 --- a/tests/OpenClaw.Tray.UITests/WorkspaceWindowProofTests.cs +++ b/tests/OpenClaw.Tray.UITests/WorkspaceWindowProofTests.cs @@ -735,80 +735,6 @@ await WaitUntilAsync(() => await WaitUntilAsync(() => IsVisible(workspaceElement, "WorkspaceOwner")); } - [Fact] - public async Task ChatComposer_EnterSendsAndShiftEnterInsertsNewline() - { - using var app = new AccessibilityAppFixture(initializeAxe: false, syntheticData: true, initialRoute: null); - await app.NavigateAsync("chat", "ChatPage", "ChatComposerInput"); - var root = AutomationElement.FromHandle(app.HubWindowHandle); - string Value() => ((ValuePattern)Find(root, "ChatComposerInput") - .GetCurrentPattern(ValuePattern.Pattern)).Current.Value; - void SetValue(string value) => ((ValuePattern)Find(root, "ChatComposerInput") - .GetCurrentPattern(ValuePattern.Pattern)).SetValue(value); - - // SendKeys targets the foreground window, so transient foreground contention - // can drop or scramble a batch. Each attempt is one full real-keyboard pass - // from a clean state; a deterministic product defect fails every attempt the - // same way and still fails the test. - for (var attempt = 1; ; attempt++) - { - SetValue(string.Empty); - var failure = await AttemptAsync(attempt); - if (failure is null) - return; - if (attempt == 4) - Assert.Fail($"Real-keyboard composer pass failed after {attempt} attempts. Last failure: {failure}"); - await Task.Delay(500); - } - - async Task AttemptAsync(int attempt) - { - try - { - // A background window can lose foreground permanently (minimized or - // stolen), which focus polling alone cannot recover from. - var window = (WindowPattern)root.GetCurrentPattern(WindowPattern.Pattern); - if (window.Current.WindowVisualState != WindowVisualState.Normal) - window.SetWindowVisualState(WindowVisualState.Normal); - await FocusForKeyboardAsync(app, root, "ChatComposerInput"); - System.Windows.Forms.SendKeys.SendWait("first line"); - if (!await WaitBrieflyAsync(() => Value() == "first line")) - return $"attempt {attempt}: typing did not land; value was '{Escape(Value())}'"; - - await FocusForKeyboardAsync(app, root, "ChatComposerInput"); - System.Windows.Forms.SendKeys.SendWait("+{ENTER}"); - System.Windows.Forms.SendKeys.SendWait("second line"); - if (!await WaitBrieflyAsync( - () => Value().ReplaceLineEndings("\n") == "first line\nsecond line")) - return $"attempt {attempt}: Shift+Enter did not produce two lines; value was '{Escape(Value())}'"; - - await FocusForKeyboardAsync(app, root, "ChatComposerInput"); - System.Windows.Forms.SendKeys.SendWait("{ENTER}"); - if (!await WaitBrieflyAsync(() => Value().Length == 0)) - return $"attempt {attempt}: Enter did not clear the composer; value was '{Escape(Value())}'"; - return null; - } - catch (Exception ex) - { - return $"attempt {attempt}: {ex.GetType().Name}: {ex.Message}"; - } - } - } - - private static string Escape(string value) => - value.Replace("\r", "{CR}").Replace("\n", "{LF}"); - - private static async Task WaitBrieflyAsync(Func predicate) - { - var timeout = Stopwatch.StartNew(); - while (timeout.Elapsed < TimeSpan.FromSeconds(2)) - { - if (predicate()) return true; - await Task.Delay(100); - } - return predicate(); - } - private static bool IsVisible(AutomationElement root, string id) => FindOrNull(root, id) is { } element && !element.Current.IsOffscreen && element.Current.BoundingRectangle.Width > 0;