From 2fa72790e05d24b5d13b723e75b0aed2ee37670d Mon Sep 17 00:00:00 2001 From: Karen Lai <7976322+karkarl@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:56:58 -0700 Subject: [PATCH 1/3] Render chat bubbles through per-message RichTextBlock for continuous selection Chat prose was rendered as many separate TextBlocks, so a user could not drag-select text across paragraphs of one message. Add a poolable RichTextBlock primitive to the in-house FunctionalUI reconciler and route both assistant and user message text through one RichTextBlock per message (a Paragraph per block), giving each message a single continuous, selection-friendly text scope while keeping chat virtualizable. - FunctionalUI: RichTextBlockElement record, RichTextBlock() factory, Set(Action), reconciler arm, ConfigureRichTextBlock (leaves Blocks to the setter), ApplyModifiers case. - ChatMarkdownRenderer: coalesce consecutive paragraph/heading blocks into one RichTextBlock; lone text blocks stay TextBlocks; lists/code/tables/etc stay separate selectable siblings. Structural block-run cache preserves active selection across re-renders independent of AST-cache eviction. Inert posture preserved (AppendInlines only). - OpenClawChatTimeline: user bubble renders as a single-Paragraph RichTextBlock preserving font, foreground, wrap and SelectionHighlightColor. - Tests: FunctionalUI primitive tests, renderer coalescing tests, updated user-bubble contract + virtualization proof text collection. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Chat/Markdown/ChatMarkdownRenderer.cs | 142 +++++++++++++++++- .../Chat/OpenClawChatTimeline.cs | 38 +++-- src/OpenClawTray.FunctionalUI/FunctionalUI.cs | 39 +++++ .../ChatUserBubbleTextContractTests.cs | 9 +- .../ChatTimelineVirtualizationProofTests.cs | 57 ++++++- .../MarkdownRendererCoalesceTests.cs | 63 ++++++++ .../RichTextBlockElementTests.cs | 54 +++++++ 7 files changed, 374 insertions(+), 28 deletions(-) create mode 100644 tests/OpenClaw.Tray.UITests/MarkdownRendererCoalesceTests.cs create mode 100644 tests/OpenClawTray.FunctionalUI.Tests/RichTextBlockElementTests.cs diff --git a/src/OpenClaw.Tray.WinUI/Chat/Markdown/ChatMarkdownRenderer.cs b/src/OpenClaw.Tray.WinUI/Chat/Markdown/ChatMarkdownRenderer.cs index 5daa2e4b8..0243f772a 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/Markdown/ChatMarkdownRenderer.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/Markdown/ChatMarkdownRenderer.cs @@ -51,18 +51,50 @@ public static class ChatMarkdownRenderer if (document.Blocks.Count == 0) return null; - if (document.Blocks.Count == 1) - return RenderBlock(document.Blocks[0]); - - var children = new List(document.Blocks.Count); - foreach (var block in document.Blocks) + // Coalesce maximal runs of consecutive text blocks (paragraphs + + // headings) into a single RichTextBlock so the whole prose run is one + // continuous text-selection scope. Non-text blocks (lists, tables, code, + // block quotes, rules) stay as their own selectable sibling controls — + // they are natural selection islands and keep their existing layout. + var blocks = document.Blocks; + var segments = new List(blocks.Count); + int i = 0; + while (i < blocks.Count) { - var rendered = RenderBlock(block); - if (rendered is not null) children.Add(rendered); + if (IsMergeableText(blocks[i])) + { + int start = i; + while (i < blocks.Count && IsMergeableText(blocks[i])) i++; + int count = i - start; + if (count == 1) + { + // A lone text block keeps the lightweight single-TextBlock + // shape (already internally selectable) — no behavior change. + var single = RenderBlock(blocks[start]); + if (single is not null) segments.Add(single); + } + else + { + var run = new List(count); + for (int j = start; j < i; j++) run.Add(blocks[j]); + segments.Add(TextRunRichBlock(run)); + } + } + else + { + var rendered = RenderBlock(blocks[i]); + if (rendered is not null) segments.Add(rendered); + i++; + } } - return VStack(8.0, children.ToArray()); + + if (segments.Count == 0) return null; + if (segments.Count == 1) return segments[0]; + return VStack(8.0, segments.ToArray()); } + private static bool IsMergeableText(MdBlock block) => block is MdParagraph or MdHeading; + public static Element? Render(string? markdown) { if (string.IsNullOrEmpty(markdown)) return null; @@ -245,6 +277,100 @@ private static Element RenderHeading(MdHeading heading) private static Element RenderParagraph(MdParagraph paragraph) => InlinesTextBlock(paragraph.Inlines).FontSize(BodyFontSize); + // ──────────────────────────────────────────────────────────────────── + // Coalesced text run → single selectable RichTextBlock + // ──────────────────────────────────────────────────────────────────── + + private const double ParagraphSpacing = 8.0; + + // Cache the block run applied to each RichTextBlock so re-renders with + // identical content skip the Blocks.Clear()+rebuild round trip. Clearing + // Blocks while the user has an active selection wipes it (same failure mode + // the TextBlock inline cache guards against). Equality is structural + // (see BlockRunsEqual) so it holds even after the bounded AST cache evicts + // and re-parses an unchanged message into fresh block instances. + private static readonly ConditionalWeakTable> + s_blocksCache = new(); + + private static RichTextBlockElement TextRunRichBlock(IReadOnlyList blocks) => + RichTextBlock().Set(rtb => + { + rtb.TextWrapping = TextWrapping.Wrap; + rtb.IsTextSelectionEnabled = true; + ApplyBlocks(rtb, blocks); + }); + + private static void ApplyBlocks(RichTextBlock rtb, IReadOnlyList blocks) + { + if (rtb.Blocks.Count > 0 + && s_blocksCache.TryGetValue(rtb, out var cached) + && BlockRunsEqual(cached, blocks)) + { + return; + } + s_blocksCache.AddOrUpdate(rtb, blocks); + rtb.Blocks.Clear(); + for (int i = 0; i < blocks.Count; i++) + { + var paragraph = BuildParagraph(blocks[i]); + if (i > 0) paragraph.Margin = new Thickness(0, ParagraphSpacing, 0, 0); + rtb.Blocks.Add(paragraph); + } + } + + // Compare coalesced text runs structurally rather than via record equality. + // MdParagraph / MdHeading records compare their MdInline lists by reference, + // so two equivalent-but-distinct block instances (produced when the bounded + // GetOrParse AST cache evicts and re-parses an unchanged message) would + // compare unequal and needlessly rebuild Blocks — wiping the active + // selection. MdInline subtypes are value-comparable, so comparing the inline + // sequences by value keeps the selection-preservation guarantee independent + // of parser-cache residency. + private static bool BlockRunsEqual(IReadOnlyList a, IReadOnlyList b) + { + if (ReferenceEquals(a, b)) return true; + if (a.Count != b.Count) return false; + for (int i = 0; i < a.Count; i++) + { + if (!BlockEqual(a[i], b[i])) return false; + } + return true; + } + + private static bool BlockEqual(MdBlock a, MdBlock b) => (a, b) switch + { + (MdHeading ha, MdHeading hb) => ha.Level == hb.Level && ha.Inlines.SequenceEqual(hb.Inlines), + (MdParagraph pa, MdParagraph pb) => pa.Inlines.SequenceEqual(pb.Inlines), + _ => a.Equals(b), + }; + + private static Paragraph BuildParagraph(MdBlock block) + { + switch (block) + { + case MdHeading heading: + { + int idx = Math.Clamp(heading.Level - 1, 0, HeadingFontSizes.Length - 1); + var p = new Paragraph + { + FontSize = HeadingFontSizes[idx], + FontWeight = MUIText.FontWeights.SemiBold, + }; + AppendInlines(p.Inlines, heading.Inlines); + return p; + } + case MdParagraph paragraph: + { + var p = new Paragraph { FontSize = BodyFontSize }; + AppendInlines(p.Inlines, paragraph.Inlines); + return p; + } + default: + // Only text blocks (see IsMergeableText) reach this method. + return new Paragraph { FontSize = BodyFontSize }; + } + } + private static Element RenderBlockQuote(MdBlockQuote quote) { var children = new List(quote.Children.Count); diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs index ffcc47817..9349e6866 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs @@ -312,6 +312,28 @@ private static void ApplyPlainSelectableInlines(TextBlock textBlock, string? tex textBlock.Inlines.Add(new Run { Text = normalized }); } + // RichTextBlock analog of the user-bubble plain-text cache. Selection lives + // on the RichTextBlock, so re-applying identical text must NOT clear Blocks + // (that wipes the active selection). Skip the rebuild when the run is + // unchanged and a paragraph is still present. + private static readonly System.Runtime.CompilerServices.ConditionalWeakTable + s_userParagraphCache = new(); + + private static void ApplyPlainSelectableParagraph(RichTextBlock richTextBlock, string? text) + { + var normalized = text ?? string.Empty; + if (richTextBlock.Blocks.Count > 0 + && s_userParagraphCache.TryGetValue(richTextBlock, out var cached) + && cached == normalized) + return; + s_userParagraphCache.AddOrUpdate(richTextBlock, normalized); + richTextBlock.Blocks.Clear(); + var paragraph = new Paragraph(); + if (normalized.Length > 0) + paragraph.Inlines.Add(new Run { Text = normalized }); + richTextBlock.Blocks.Add(paragraph); + } + // Cache parsed markdown text per TextBlock to avoid re-clearing and // rebuilding Inlines on every re-render when message content is stable. private static readonly System.Runtime.CompilerServices.ConditionalWeakTable @@ -1155,7 +1177,7 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst bool isHighContrast = TryDetectHighContrast(); var selectionHighlightBrush = GetUserBubbleSelectionBrush(isHighContrast); bubbleChildren.Add( - TextBlock(string.Empty) + RichTextBlock() .Set(t => { t.TextWrapping = TextWrapping.Wrap; @@ -1187,14 +1209,12 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst // color the OS guarantees contrasts with both // surfaces. t.SelectionHighlightColor = selectionHighlightBrush; - // Render through Inlines (a single Run) rather - // than the .Text property. This matches the - // assistant bubble's selection-safe path and - // sidesteps a WinUI bug where setting Text on a - // selection-enabled TextBlock during a re-render - // triggered by the mouse-up that ends a drag- - // select leaves the glyph layer visually empty. - ApplyPlainSelectableInlines(t, messageText); + // Render the message as a single Paragraph (one Run) + // so the whole user message is one continuous + // selection scope — matching the assistant bubble's + // RichTextBlock append-block pattern. The plain-text + // run keeps the bubble inert (no markdown / links). + ApplyPlainSelectableParagraph(t, messageText); })); } Element content; diff --git a/src/OpenClawTray.FunctionalUI/FunctionalUI.cs b/src/OpenClawTray.FunctionalUI/FunctionalUI.cs index 5b83efad7..9fa9703f5 100644 --- a/src/OpenClawTray.FunctionalUI/FunctionalUI.cs +++ b/src/OpenClawTray.FunctionalUI/FunctionalUI.cs @@ -185,6 +185,12 @@ public static GridSize Parse(string value) } public sealed record TextBlockElement(string Text) : Element; +// A RichTextBlock host. Unlike TextBlockElement it carries no text of its own — +// its Blocks (Paragraphs / InlineUIContainers) are populated imperatively by a +// caller-supplied setter (see the Set(Action) overload), exactly +// as TextBlockElement's Inlines are. Used by the chat renderer to give a whole +// message one continuous text-selection scope across multiple paragraphs. +public sealed record RichTextBlockElement : Element; public sealed record TextFieldElement(string Value, Action? OnChanged, string? Placeholder, string? Header) : Element; public sealed record PasswordBoxElement(string Password, Action? OnChanged, string? Placeholder) : Element; public sealed record ButtonElement(string Label, Action? OnClick, Element? ContentElement = null) : Element @@ -568,6 +574,7 @@ public static class Factories { public static BorderElement Empty() => new(null); public static TextBlockElement TextBlock(string text) => new(text); + public static RichTextBlockElement RichTextBlock() => new(); public static TextBlockElement Caption(string text) => TextBlock(text).FontSize(12); public static TextFieldElement TextField(string value, Action? onChanged = null, string? placeholder = null, string? header = null) => new(value, onChanged, placeholder, header); @@ -744,6 +751,7 @@ public static TextBlockElement SemiBold(this TextBlockElement element) => element.FontWeight(Microsoft.UI.Text.FontWeights.SemiBold); public static TextBlockElement Set(this TextBlockElement element, Action setter) => element.AddSetter(setter); + public static RichTextBlockElement Set(this RichTextBlockElement element, Action setter) => element.AddSetter(setter); public static TextFieldElement Set(this TextFieldElement element, Action setter) => element.AddSetter(setter); public static PasswordBoxElement Set(this PasswordBoxElement element, Action setter) => element.AddSetter(setter); public static ButtonElement Set(this ButtonElement element, Action