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
18 changes: 18 additions & 0 deletions src/OpenClaw.Shared/Markdown/ChatMarkdownAst.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,24 @@ public sealed record MdTableRow(IReadOnlyList<MdTableCell> Cells);

public sealed record MdTableCell(IReadOnlyList<MdInline> Inlines);

/// <summary>
/// Base type for all inline AST nodes.
/// <para>
/// IMPORTANT — cache invariant: <c>ChatMarkdownRenderer</c> uses
/// <c>IReadOnlyList&lt;MdInline&gt;.SequenceEqual</c> (record value-equality)
/// to short-circuit rebuilding <c>TextBlock.Inlines</c> on re-render, which
/// is what keeps a user's text selection alive across pointer-enter/leave
/// and streaming token updates. That short-circuit is sound ONLY while every
/// concrete <c>MdInline</c> subtype contains exclusively value-comparable
/// members (primitives, <c>string</c>, enums). If a future subtype adds a
/// reference-typed member (e.g. <c>IReadOnlyList&lt;MdInline&gt; Children</c>
/// for links), the auto-generated record <c>Equals</c> will compare those
/// members BY REFERENCE — silently breaking cache correctness and wiping
/// selection again on every re-render. Update the renderer's equality
/// strategy (and <c>MdInlineEqualityTests</c>) before introducing such a
/// member.
/// </para>
/// </summary>
public abstract record MdInline;

/// <summary>
Expand Down
34 changes: 32 additions & 2 deletions src/OpenClaw.Tray.WinUI/Chat/Markdown/ChatMarkdownRenderer.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Documents;
Expand Down Expand Up @@ -479,15 +481,43 @@ private static int MaxCells(IReadOnlyList<MdTableRow> a, IReadOnlyList<MdTableRo
// Inline → TextBlock.Inlines
// ────────────────────────────────────────────────────────────────────

// Cache the inline list applied to each TextBlock so that re-renders
// with identical content can skip the Clear()+AppendInlines() round
// trip. FunctionalUI pools TextBlocks by tree path and re-runs every
// setter on each render (see FunctionalUI.ConfigureTextBlock); clearing
// the Inlines collection while the user has an active text selection
// wipes that selection (bug: selection disappears when the pointer
// leaves the bubble, because hover-state changes trigger a full
// subtree re-render). MdInline subtypes are records with value
// equality, so SequenceEqual is a sound structural comparison.
private static readonly ConditionalWeakTable<TextBlock, IReadOnlyList<MdInline>>
s_inlinesCache = new();

private static TextBlockElement InlinesTextBlock(IReadOnlyList<MdInline> inlines) =>
TextBlock(string.Empty).Set(tb =>
{
tb.TextWrapping = TextWrapping.Wrap;
tb.IsTextSelectionEnabled = true;
tb.Inlines.Clear();
AppendInlines(tb.Inlines, inlines);
ApplyInlines(tb, inlines);
});

private static void ApplyInlines(TextBlock tb, IReadOnlyList<MdInline> inlines)
{
// Only honour the cache when the previously-built inlines are still
// present. ConfigureTextBlock may set Text="" before this setter
// runs, which clears the Inlines collection out from under us; in
// that case we must rebuild even if the source list is unchanged.
if (tb.Inlines.Count > 0
&& s_inlinesCache.TryGetValue(tb, out var cached)
&& (ReferenceEquals(cached, inlines) || cached.SequenceEqual(inlines)))
{
return;
}
s_inlinesCache.AddOrUpdate(tb, inlines);
tb.Inlines.Clear();
AppendInlines(tb.Inlines, inlines);
}

private static void AppendInlines(InlineCollection sink, IReadOnlyList<MdInline> inlines)
{
foreach (var inline in inlines)
Expand Down
127 changes: 104 additions & 23 deletions src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,42 @@ private static Element SafeMarkdownText(string? text)
private static readonly System.Runtime.CompilerServices.ConditionalWeakTable<TextBlock, string>
s_plainCache = new();

// FontFamily instances are immutable, but `new FontFamily(...)` per
// render allocates a fresh CLR object whose reference does not equal
// the previous one. Reassigning a referentially-different FontFamily
// to a TextBlock invalidates its inline runs even when the source
// string is identical, which (in the tool-output panel) makes
// multi-line wrapped text vanish during a pointer-exit re-render.
// Caching sidesteps both the GC pressure and the invalidation.
//
// FontFamily is a DependencyObject with thread affinity, so a single
// process-wide singleton would crash with RPC_E_WRONG_THREAD if a
// second window on a different dispatcher ever tried to read it.
// Keying by DispatcherQueue mirrors the brush cache above: one
// shared instance per window, collected with its dispatcher.
// Off-dispatcher callers (tests, design-time) get a one-shot
// uncached instance — correct, just not reused.
private const string MonoFontFamilySource = "Cascadia Code, Cascadia Mono, Consolas";
private static readonly System.Runtime.CompilerServices.ConditionalWeakTable<
Microsoft.UI.Dispatching.DispatcherQueue, FontFamily> s_monoFontByDispatcher = new();
private static FontFamily s_monoFontFamily
{
get
{
var dispatcher = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread();
if (dispatcher is null)
{
return new FontFamily(MonoFontFamilySource);
}
if (!s_monoFontByDispatcher.TryGetValue(dispatcher, out var family))
{
family = new FontFamily(MonoFontFamilySource);
s_monoFontByDispatcher.Add(dispatcher, family);
}
return family;
}
}

// Per-DispatcherQueue selection-highlight brushes for the user
// bubble. The bubble background is the user's chosen system accent
// (which may be red, green, purple, …), so a hardcoded color would
Expand Down Expand Up @@ -1433,7 +1469,7 @@ Element BuildRow(ChatTimelineItem entry, bool isFirst, bool isLast, string? step
Caption(labelText).Foreground(SecondaryText)
.Set(t =>
{
t.FontFamily = new FontFamily("Cascadia Code, Cascadia Mono, Consolas");
t.FontFamily = s_monoFontFamily;
t.FontWeight = Microsoft.UI.Text.FontWeights.SemiBold;
})
.VAlign(VerticalAlignment.Center)
Expand Down Expand Up @@ -1472,6 +1508,7 @@ Element BuildRow(ChatTimelineItem entry, bool isFirst, bool isLast, string? step
).Set(b => b.MinHeight = 32);

Element body = Empty();
bool hasExpandedBody = false;
if (isExpanded)
{
var sections = new System.Collections.Generic.List<Element>();
Expand Down Expand Up @@ -1507,14 +1544,31 @@ Element PhantomChevron() => Caption("▸")

var codeBlock = Border(
ScrollView(
TextBlock(displayText)
// Use Text="" + Inlines populated by
// ApplyPlainSelectableInlines so that the
// ConditionalWeakTable cache short-circuits
// re-population on hover-out re-renders.
// Setting Text directly (even value-guarded)
// leaves the rendered run unanchored, which
// lets WinUI drop the text glyphs during a
// pointer-exit re-render while keeping any
// active selection rectangles around. This
// mirrors the working markdown-bubble path.
TextBlock("")
.Set(t =>
{
t.FontFamily = new FontFamily("Cascadia Code, Cascadia Mono, Consolas");
// Hoisted static FontFamily is the
// critical bit — reassigning the same
// reference is a DP-equality no-op,
// so this setter is safe to re-run on
// every render without invalidating
// the selection.
t.FontFamily = s_monoFontFamily;
t.FontSize = 11;
t.TextWrapping = TextWrapping.Wrap;
t.IsTextSelectionEnabled = true;
t.LineHeight = 16;
ApplyPlainSelectableInlines(t, displayText);
})
.Foreground(SecondaryText)
.Padding(11, 8, 11, 10)
Expand Down Expand Up @@ -1558,7 +1612,25 @@ Element PhantomChevron() => Caption("▸")
sections.Add(BuildSection(outLabel, entry.ToolOutput!));
}

body = Border(VStack(0, sections.ToArray())).Background(blockHeaderBg);
hasExpandedBody = sections.Count > 0;
if (hasExpandedBody)
{
var bodyBorder = Border(VStack(0, sections.ToArray())).Background(blockHeaderBg);
// When this row is the last in the card AND expanded
// with actual content, the body sits at the bottom
// edge — it must own the bottom rounded corners so
// the row blends into the card instead of showing a
// square edge clipped by the card's rounding.
if (isLast)
{
bodyBorder = bodyBorder.Set(b => b.CornerRadius = new CornerRadius(0, 0, 8, 8));
}
body = bodyBorder;
}
// sections.Count == 0 leaves `body` as the default
// Empty() above so no phantom bordered strip stacks under
// the header. The header keeps its own bottom rounding
// via hasExpandedBody=false.
}

Action toggle = () =>
Expand All @@ -1569,32 +1641,26 @@ Element PhantomChevron() => Caption("▸")
expandedToolChips.Set(next);
};

// 1px separator between rows (skipped on the first row). The
// separator lives inside the row so collapsed/expanded heights
// both keep continuity with the next row above.
var rowContent = VStack(0, headerRow, body);
var rowWithSeparator = isFirst
? (Element)rowContent
: Border(rowContent)
.Set(b =>
{
b.BorderThickness = new Thickness(0, 1, 0, 0);
b.BorderBrush = toolCardBorderBrush;
});

return Button(rowWithSeparator, toggle)
// Scope the toggle Button to the header only — the body
// contains selectable TextBlocks (TOOL OUTPUT / CALL), and
// wrapping body in the Button caused unhandled PointerReleased
// events inside the body's padding/whitespace to bubble up
// and fire Click → collapse the section while the user was
// trying to select text.
var headerButton = Button(headerRow, toggle)
.Set(b =>
{
b.HorizontalAlignment = HorizontalAlignment.Stretch;
b.HorizontalContentAlignment = HorizontalAlignment.Stretch;
b.Padding = new Thickness(0);
// Round only the outer corners so rows blend into the
// wrapping card without leaving gaps.
// Top corners follow isFirst. Bottom corners follow
// isLast unless the expanded body below owns them
// (i.e., expanded AND has at least one section).
b.CornerRadius = new CornerRadius(
isFirst ? 8 : 0,
isFirst ? 8 : 0,
isLast ? 8 : 0,
isLast ? 8 : 0);
(isLast && !hasExpandedBody) ? 8 : 0,
(isLast && !hasExpandedBody) ? 8 : 0);
})
.Resources(r => r
.Set("ButtonBackground", new SolidColorBrush(Colors.Transparent))
Expand All @@ -1609,6 +1675,21 @@ Element PhantomChevron() => Caption("▸")
.Set("ButtonBorderBrush", new SolidColorBrush(Colors.Transparent))
.Set("ButtonBorderBrushPointerOver", new SolidColorBrush(Colors.Transparent))
.Set("ButtonBorderBrushPressed", new SolidColorBrush(Colors.Transparent)));

// 1px separator between rows (skipped on the first row). The
// separator lives inside the row so collapsed/expanded heights
// both keep continuity with the next row above.
var rowStack = VStack(0, headerButton, body);
var rowWithSeparator = isFirst
? (Element)rowStack
: Border(rowStack)
.Set(b =>
{
b.BorderThickness = new Thickness(0, 1, 0, 0);
b.BorderBrush = toolCardBorderBrush;
});

return rowWithSeparator;
}

// ── Style-aware composition ──────────────────────────────
Expand Down Expand Up @@ -2307,7 +2388,7 @@ Element RenderPermissionEntry(ChatTimelineItem entry)
{
t.FontSize = 12;
t.TextWrapping = TextWrapping.Wrap;
t.FontFamily = new FontFamily("Cascadia Code, Cascadia Mono, Consolas");
t.FontFamily = s_monoFontFamily;
})
.Foreground(TertiaryText)
.Padding(0, 4, 0, 4),
Expand Down
Loading
Loading