Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/WINDOWS_NODE_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 16 additions & 5 deletions src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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<ChatAttachmentPresentation> Attachments) ParseAttachments(string? text)
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 4 additions & 2 deletions src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,10 @@

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.UI.Reactor" Version="0.1.0-preview.15" />
<PackageReference Include="Microsoft.UI.Reactor.Advanced" Version="0.1.0-preview.15" />
<!-- Keep both on preview.12 until the WinUI ItemsView fix ships and is validated.
Retirement gates and Markdown ListItem workaround: openclaw/openclaw-windows-node#1424. -->
<PackageReference Include="Microsoft.UI.Reactor" Version="0.1.0-preview.12" />
<PackageReference Include="Microsoft.UI.Reactor.Advanced" Version="0.1.0-preview.12" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="$(MicrosoftWindowsAppSDKVersion)" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="$(MicrosoftWindowsSdkBuildToolsVersion)" />
<PackageReference Include="OpenTelemetry" Version="1.16.0" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
147 changes: 147 additions & 0 deletions tests/OpenClaw.Tray.UITests/ReactorMarkdownListProofTests.cs
Original file line number Diff line number Diff line change
@@ -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<Grid>(root!), IsListRow);
var markerBlock = Assert.IsType<TextBlock>(row.Children[0]);
var content = Assert.IsType<RichTextBlock>(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<Grid>(root!), IsListRow);
var content = Assert.IsType<RichTextBlock>(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<Grid>(host!).Where(IsListRow).ToArray();
Assert.Equal(3, rows.Length);
Assert.All(rows, row =>
{
Assert.Equal("\u2022 ", Assert.IsType<TextBlock>(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<RichTextBlock>(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<Run> GetRuns(RichTextBlock text) =>
text.Blocks.OfType<Paragraph>().SelectMany(paragraph => paragraph.Inlines.OfType<Run>());

private static string CollectText(RichTextBlock text) =>
string.Concat(GetRuns(text).Select(run => run.Text));
}
Loading