From 3cda150580b1ade5448d75400ba5c252925bb824 Mon Sep 17 00:00:00 2001 From: Karen Lai <7976322+karkarl@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:04:24 -0700 Subject: [PATCH] fix(chat): avoid session-switch crashes without clipping Markdown lists Pin both Reactor packages to preview.12 and restore finite-width list layout through MarkdownOptions.ListItem. Preserve row-targeted scrolling and document removal gates in #1424. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0b09606a-3b94-4974-87d1-2068b0e79e76 --- docs/WINDOWS_NODE_TESTING.md | 25 +++ .../Chat/ReactorChatTimeline.cs | 21 ++- .../OpenClaw.Tray.WinUI.csproj | 6 +- .../ReactorChatMarkdownContractTests.cs | 1 + .../ReactorMarkdownListProofTests.cs | 147 ++++++++++++++++++ 5 files changed, 193 insertions(+), 7 deletions(-) create mode 100644 tests/OpenClaw.Tray.UITests/ReactorMarkdownListProofTests.cs diff --git a/docs/WINDOWS_NODE_TESTING.md b/docs/WINDOWS_NODE_TESTING.md index ffc7c1c37..ce891b2ab 100644 --- a/docs/WINDOWS_NODE_TESTING.md +++ b/docs/WINDOWS_NODE_TESTING.md @@ -28,6 +28,31 @@ For changes touching tray UX, Settings, onboarding, chat/canvas, Command Center, Short version: run required tests, collect a closeout proof pass with `.\run-app-local.ps1 -Isolated` when UI is involved, use computer-use or developer-provided screenshots/output for the active changed UI state, prove MCP with `winnode` or raw JSON-RPC, prove gateway paths when available, and include current-head concrete output under `## Real behavior proof`. Mid-development computer-use/MCP/rubber-duck validation is fine when explicitly requested or needed to unblock work. +### Reactor preview.12 compatibility proof + +Both Reactor packages are temporarily pinned to `0.1.0-preview.12` while +[microsoft/microsoft-ui-xaml#11865](https://github.com/microsoft/microsoft-ui-xaml/issues/11865) +awaits a released and validated fix. `ReactorChatTimeline.BuildSafeMarkdown` +uses `MarkdownOptions.ListItem` to replace the default horizontal list row with +an Auto-marker/Star-content Grid, preserving the existing marker, content and +spacing. This locally supplies the wrapping fix from +[microsoft/microsoft-ui-reactor#1197](https://github.com/microsoft/microsoft-ui-reactor/pull/1197) +without adopting preview.13's row eviction behavior. + +Run `ReactorMarkdownListProofTests` in `OpenClaw.Tray.UITests` on the host's +native architecture. These mounted tests check ordered/unordered wrapping at +240 DIPs, reflow at 600 DIPs, and nested/loose list content and formatting. +Keep the existing table and disposal proof in the focused run. + +Before release or removing the pin, also exercise repeated session switching +while a bring-into-view request is pending, using 240 mixed-height messages. +Verify that message 240 is actually visible, not just that the current scroll +extent was reached. Verify streaming tail-follow and arrivals while scrolled +up. Do not substitute extent-only scrolling: the alternative workaround +stopped near message 233 in the reported scenario. +[Tracking issue #1424](https://github.com/openclaw/openclaw-windows-node/issues/1424) +owns the package upgrade and removal gates. + ### New command MCP contract Every new Windows node call must be exposed through local MCP and `winnode`: register the capability, update `McpToolBridge.CommandDescriptions`, update `.agents/skills/winnode/SKILL.md`, add focused tests, and prove discovery/invocation with `winnode` or raw MCP JSON-RPC. diff --git a/src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs index bc02cc6ce..081e31626 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs @@ -550,6 +550,7 @@ internal static Element BuildSafeMarkdown(string? text) var options = new MarkdownOptions { ParserFlags = MarkdownParserFlags.Tables | MarkdownParserFlags.NoHtml, + ListItem = BuildWrappingMarkdownListItem, Image = (alt, _) => Text( string.IsNullOrWhiteSpace(alt) ? "[Image]" : $"[Image: {alt}]", 14, @@ -565,9 +566,19 @@ internal static Element BuildSafeMarkdown(string? text) .IsTextSelectionEnabled(true), }; - // Fully qualified: the markdown factory ships in Microsoft.UI.Reactor.Advanced, and importing - // that namespace would make the simple name `Factories` ambiguous with Microsoft.UI.Reactor.Factories. - return Microsoft.UI.Reactor.Advanced.Factories.Markdown(ChatMarkdownSanitizer.Sanitize(text), options); + return Microsoft.UI.Reactor.Factories.Markdown(ChatMarkdownSanitizer.Sanitize(text), options); + } + + private static Element BuildWrappingMarkdownListItem(Element defaultElement) + { + // preview.12 measures list content at infinite width in an HStack. Use the + // Auto/Star layout from microsoft/microsoft-ui-reactor#1197 until #1424 retires the pin. + if (defaultElement is not StackElement { Orientation: Orientation.Horizontal, Children.Length: 2 } row) + throw new InvalidOperationException("Unexpected Reactor Markdown list item shape. Review the preview.12 workaround."); + + return Grid([GridSize.Auto, GridSize.Star()], [], + row.Children[0], + row.Children[1].Grid(column: 1)) with { ColumnSpacing = row.Spacing }; } private static Element BuildAssistantFooter( @@ -652,7 +663,7 @@ private static Element CompactIconAction( .OnLostFocus((_, _) => onLostFocus()) .Opacity(isVisible ? 1 : 0) .IsTabStop(true) - .IsHitTestVisible(isVisible); + .Set(button => button.IsHitTestVisible = isVisible); } private static (string Message, IReadOnlyList Attachments) ParseAttachments(string? text) @@ -1024,7 +1035,7 @@ private static Element HoverMetadata(Element child, bool isHovered) { return Border(child) .Opacity(isHovered ? 1 : 0) - .IsHitTestVisible(false); + .Set(border => border.IsHitTestVisible = false); } private static TextBlockElement Text( diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index 818ae1657..7fa6b922d 100644 --- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj +++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -94,8 +94,10 @@ - - + + + diff --git a/tests/OpenClaw.Tray.Tests/ReactorChatMarkdownContractTests.cs b/tests/OpenClaw.Tray.Tests/ReactorChatMarkdownContractTests.cs index c21d03f37..7ccb1bac5 100644 --- a/tests/OpenClaw.Tray.Tests/ReactorChatMarkdownContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/ReactorChatMarkdownContractTests.cs @@ -23,6 +23,7 @@ public void AssistantMessages_UseSanitizedGitHubFlavoredMarkdown() Assert.Contains("Image = (alt, _) => Text(", timeline, StringComparison.Ordinal); Assert.Contains("LinkBuilder = (children, _) => HStack(children)", timeline, StringComparison.Ordinal); Assert.Contains("HtmlBlock = raw => Text(", timeline, StringComparison.Ordinal); + Assert.Contains("ListItem = BuildWrappingMarkdownListItem", timeline, StringComparison.Ordinal); Assert.Contains( ".AutomationName(BuildAccessibleAssistantText(entry.Text, metadata?.AssistantContent))", timeline, diff --git a/tests/OpenClaw.Tray.UITests/ReactorMarkdownListProofTests.cs b/tests/OpenClaw.Tray.UITests/ReactorMarkdownListProofTests.cs new file mode 100644 index 000000000..a11029cc7 --- /dev/null +++ b/tests/OpenClaw.Tray.UITests/ReactorMarkdownListProofTests.cs @@ -0,0 +1,147 @@ +using Microsoft.UI.Reactor.Hosting; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Documents; +using OpenClawTray.Chat; +using OpenClawTray.Helpers; +using static OpenClaw.Tray.UITests.TestSupport; + +namespace OpenClaw.Tray.UITests; + +[Collection(UICollection.Name)] +public sealed class ReactorMarkdownListProofTests +{ + private const string LongText = + "This list item contains enough words to wrap across several lines in a narrow chat bubble " + + "while preserving the final words of the message."; + + private readonly UIThreadFixture _ui; + + public ReactorMarkdownListProofTests(UIThreadFixture ui) => _ui = ui; + + [Theory] + [InlineData("- ", "\u2022 ")] + [InlineData("7. ", "7. ")] + public async Task ListContent_WrapsWithinRemainingWidthAndReflowsOnResize(string prefix, string marker) + { + await _ui.ResetContainerAsync(); + ReactorHostControl? host = null; + UIElement? root = null; + double narrowHeight = 0; + try + { + await _ui.RunOnUIAsync(() => + { + TestApp.EnsureFluentBrushFallbacks(Application.Current.Resources); + host = new ReactorHostControl { Width = 240, VerticalAlignment = VerticalAlignment.Top }; + root = host.Reconciler.Mount( + ReactorChatTimeline.BuildSafeMarkdown(prefix + LongText), static () => { }); + host.Content = root; + _ui.Container.Children.Add(host); + }); + await _ui.YieldToRenderAsync(); + await _ui.RunOnUIAsync(() => + { + _ui.Container.UpdateLayout(); + var row = Assert.Single(FindLogical(root!), IsListRow); + var markerBlock = Assert.IsType(row.Children[0]); + var content = Assert.IsType(row.Children[1]); + Assert.Equal(marker, markerBlock.Text); + Assert.Equal(LongText, CollectText(content)); + Assert.Equal(TextWrapping.Wrap, content.TextWrapping); + Assert.True(content.IsTextSelectionEnabled); + Assert.Equal(1, Grid.GetColumn(content)); + Assert.InRange(content.ActualWidth, 1, 239); + Assert.True(content.ActualWidth + markerBlock.ActualWidth + row.ColumnSpacing <= 241); + narrowHeight = content.ActualHeight; + Assert.True(narrowHeight > content.FontSize * 2); + }); + await _ui.RunOnUIAsync(() => VisualTestCapture.CaptureAsync(_ui.Container, "ReactorMarkdownListNarrow")); + await _ui.RunOnUIAsync(() => { host!.Width = 600; }); + await _ui.YieldToRenderAsync(); + await _ui.RunOnUIAsync(() => + { + _ui.Container.UpdateLayout(); + var row = Assert.Single(FindLogical(root!), IsListRow); + var content = Assert.IsType(row.Children[1]); + Assert.True(content.ActualHeight < narrowHeight); + Assert.Equal(LongText, CollectText(content)); + Assert.InRange(content.ActualWidth, 241, 600); + }); + await _ui.RunOnUIAsync(() => VisualTestCapture.CaptureAsync(_ui.Container, "ReactorMarkdownListWide")); + } + finally + { + if (host is not null) + await _ui.RunOnUIAsync(() => + { + _ui.Container.Children.Remove(host); + host.Content = null; + host.Dispose(); + }); + } + } + + [Fact] + public async Task NestedAndLooseLists_PreserveMarkersFormattingAndBlockContent() + { + await _ui.ResetContainerAsync(); + ReactorHostControl? host = null; + try + { + await _ui.RunOnUIAsync(() => + { + TestApp.EnsureFluentBrushFallbacks(Application.Current.Resources); + host = new ReactorHostControl { Width = 280, VerticalAlignment = VerticalAlignment.Top }; + var root = host.Reconciler.Mount(ReactorChatTimeline.BuildSafeMarkdown( + "- **outer bold**\n\n second paragraph\n\n - nested item\n\n" + + "- final item\n\n > quoted content"), static () => { }); + host.Content = root; + _ui.Container.Children.Add(host); + }); + await _ui.YieldToRenderAsync(); + await _ui.RunOnUIAsync(() => + { + _ui.Container.UpdateLayout(); + var rows = FindLogical(host!).Where(IsListRow).ToArray(); + Assert.Equal(3, rows.Length); + Assert.All(rows, row => + { + Assert.Equal("\u2022 ", Assert.IsType(row.Children[0]).Text); + Assert.Equal(1, Grid.GetColumn((FrameworkElement)row.Children[1])); + Assert.InRange(((FrameworkElement)row.Children[1]).ActualWidth, 1, 279); + }); + var texts = FindLogical(host!).ToArray(); + Assert.Contains(texts, text => CollectText(text) == "second paragraph"); + Assert.Contains(texts, text => CollectText(text) == "nested item"); + Assert.Contains(texts, text => CollectText(text) == "quoted content"); + var bold = Assert.Single(texts, text => CollectText(text) == "outer bold"); + Assert.Contains(GetRuns(bold), run => run.FontWeight.Weight > 400); + }); + await _ui.RunOnUIAsync(() => VisualTestCapture.CaptureAsync(_ui.Container, "ReactorMarkdownListNested")); + } + finally + { + if (host is not null) + await _ui.RunOnUIAsync(() => + { + _ui.Container.Children.Remove(host); + host.Content = null; + host.Dispose(); + }); + } + } + + private static bool IsListRow(Grid grid) => + grid.ColumnDefinitions.Count == 2 && + grid.ColumnDefinitions[0].Width.IsAuto && + grid.ColumnDefinitions[1].Width.IsStar && + grid.Children.Count == 2 && + grid.Children[0] is TextBlock; + + private static IEnumerable GetRuns(RichTextBlock text) => + text.Blocks.OfType().SelectMany(paragraph => paragraph.Inlines.OfType()); + + private static string CollectText(RichTextBlock text) => + string.Concat(GetRuns(text).Select(run => run.Text)); +}