diff --git a/src/OpenClaw.Shared/SettingsData.cs b/src/OpenClaw.Shared/SettingsData.cs
index 321c0c3fe..baf59bfb4 100644
--- a/src/OpenClaw.Shared/SettingsData.cs
+++ b/src/OpenClaw.Shared/SettingsData.cs
@@ -83,6 +83,8 @@ public record class SettingsData
public float SttSilenceTimeout { get; set; } = 2.5f;
/// Enable TTS playback of responses during voice sessions.
public bool VoiceTtsEnabled { get; set; } = true;
+ /// Show tool-call and usage chips inline in the chat timeline.
+ public bool ShowChatToolCalls { get; set; } = true;
/// Play audio feedback chimes on listen start/stop.
public bool VoiceAudioFeedback { get; set; } = true;
public bool NodeTtsEnabled { get; set; } = false;
diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs
index 5084dc421..b97983057 100644
--- a/src/OpenClaw.Tray.WinUI/App.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs
@@ -494,6 +494,9 @@ private async Task OnLaunchedAsync(LaunchActivatedEventArgs args)
// Initialize settings before update check so skip selections can be remembered.
_settings = new SettingsManager();
+ // Seed chat tool-call visibility from persisted settings so the timeline
+ // honors the Settings > Chat "Show tool calls and usage" toggle on launch.
+ OpenClawTray.Chat.OpenClawChatRoot.SetToolCallsVisible(_settings.ShowChatToolCalls);
_previousSettingsSnapshot = _settings.ToSettingsData().ToConnectionSnapshot();
_openTelemetryConnection = new OpenTelemetryEndpointConnection();
await _openTelemetryConnection.ApplyAsync(
diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChannelGroup.cs b/src/OpenClaw.Tray.WinUI/Chat/ChannelGroup.cs
index 004156514..aa7f1ec27 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/ChannelGroup.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/ChannelGroup.cs
@@ -1,3 +1,5 @@
namespace OpenClawTray.Chat;
-public record ChannelGroup(string AgentLabel, (string Id, string Title)[] Sessions);
+public record ChannelGroup(
+ string AgentLabel,
+ (string Id, string Title, string? Model, string? ModelProvider)[] Sessions);
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs
index 35aa5faf9..686be483f 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs
@@ -28,6 +28,22 @@ public sealed class OpenClawChatRoot : Component
private static int s_toolCallsCollapseVersion;
private static event EventHandler? ToolCallsVisibilityChanged;
+ ///
+ /// Sets whether tool-call / usage chips are shown in the chat timeline. This
+ /// is the single writer for the tool-call visibility state now that the
+ /// toggle lives in the Settings "Chat" section (previously a composer
+ /// toggle). Bumps the collapse version when hiding so already-expanded tool
+ /// chips collapse, updates the shared static, and notifies any mounted
+ /// so its timeline re-renders.
+ ///
+ public static void SetToolCallsVisible(bool visible)
+ {
+ if (!visible && s_showToolCalls)
+ s_toolCallsCollapseVersion++;
+ s_showToolCalls = visible;
+ ToolCallsVisibilityChanged?.Invoke(null, EventArgs.Empty);
+ }
+
private readonly IChatDataProvider _provider;
private readonly string? _initialThreadId;
private readonly Func? _onReadAloud;
@@ -530,7 +546,7 @@ Element BuildLoadingElement()
.ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase)
.Select(g => new ChannelGroup(
AgentLabel: g.Key.Length > 0 ? char.ToUpperInvariant(g.Key[0]) + g.Key[1..] : "Unknown",
- Sessions: g.Select(t => (Id: t.Id, Title: t.Title!)).ToArray()))
+ Sessions: g.Select(t => (Id: t.Id, Title: t.Title!, Model: t.Model, ModelProvider: t.ModelProvider)).ToArray()))
.ToArray();
// If the compose-only synthetic thread isn't represented in any group
@@ -551,7 +567,7 @@ Element BuildLoadingElement()
var agentLabel = agentId.Length > 0 ? char.ToUpperInvariant(agentId[0]) + agentId[1..] : "Main";
var syntheticGroup = new ChannelGroup(
AgentLabel: agentLabel,
- Sessions: new[] { (Id: effectiveThread.Id!, Title: effectiveThread.Title ?? "OpenClaw Windows Tray") });
+ Sessions: new[] { (Id: effectiveThread.Id!, Title: effectiveThread.Title ?? "OpenClaw Windows Tray", Model: effectiveThread.Model, ModelProvider: effectiveThread.ModelProvider) });
var augmented = new ChannelGroup[channelGroups.Length + 1];
augmented[0] = syntheticGroup;
@@ -607,14 +623,6 @@ Element BuildLoadingElement()
VoiceAudioLevel: voiceAudioLevel.Value,
RegisterVoiceStarter: starter => TriggerVoiceRecording = starter,
OnAttachmentPasted: att => SetPendingAttachments(pendingAttachmentsRef.Current.Concat(new[] { att }).ToArray()),
- ShowToolCalls: showToolCalls.Value,
- OnShowToolCallsChanged: visible =>
- {
- if (!visible && s_showToolCalls)
- s_toolCallsCollapseVersion++;
- s_showToolCalls = visible;
- ToolCallsVisibilityChanged?.Invoke(null, EventArgs.Empty);
- },
IsCompact: _isCompact,
AvailableCommands: snapshot.AvailableCommands,
CommandsSupported: snapshot.CommandsSupported,
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs
index ceb8788ed..b10ba81de 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs
@@ -16,27 +16,36 @@
using Windows.UI;
using static OpenClawTray.FunctionalUI.Factories;
using static OpenClawTray.FunctionalUI.Core.Theme;
+using CoreMenuFlyoutItemBase = OpenClawTray.FunctionalUI.Core.MenuFlyoutItemBase;
namespace OpenClawTray.Chat;
///
-/// Three-row composer surface that mirrors Kenny Hong's ChatShell XAML
-/// design (kenehong/native-chat-v2):
+/// Unified chat composer surface matching the seeded design-system
+/// ChatComposer pattern (see .agents/design/components.jsx).
///
-///
-/// - Row 1 — three compact es:
-/// Channel (agent identity), Model, and Reasoning mode.
-/// - Row 2 — multi-line message
-/// with Message Assistant (Enter to send) placeholder.
-/// - Row 3 — four right-aligned action buttons (transparent attach / mic / more,
-/// plus a filled accent Send button).
+/// A single rounded (Fluent OverlayCornerRadius / 8px), 1px-line-bordered card
+/// holds a transparent auto-growing message
+/// ABOVE a space-between
+/// toolbar row:
+///
+///
+/// - LEFT cluster — an Add (+) subtle icon button followed by
+/// subtle text+chevron pickers for the session/channel, model, and reasoning
+/// effort (menu-flyout dropdowns, not ComboBoxes).
+/// - RIGHT cluster — Dictate and speaker subtle icon buttons
+/// plus one primary action slot that switches between accent Send and
+/// neutral Stop.
///
///
-/// Replaces the original InputBar + StatusBar pair from the
-/// previous native chat prototype so our chat surface no longer carries two
-/// separate footer rows. The status, working indicator, and permission
-/// banner that InputBar used to render are preserved here above the
-/// composer.
+/// Only Send carries the accent; every other control is subtle and uses
+/// Fluent theme resources (SubtleFillColorSecondaryBrush on hover,
+/// SubtleFillColorTertiaryBrush on press) so light/dark/high-contrast
+/// stay correct with no hard-coded colors. The surface border turns accent while
+/// recording. Tool-call/usage visibility now lives in the Settings page "Chat"
+/// section; speaker mute remains available inline and is mirrored by the
+/// read-aloud setting. The working indicator and permission banner render above
+/// the composer / inline in the timeline respectively.
///
public record OpenClawComposerProps(
string ConnectionState,
@@ -68,8 +77,6 @@ public record OpenClawComposerProps(
float VoiceAudioLevel = 0f,
Action? RegisterVoiceStarter = null,
Action? OnAttachmentPasted = null,
- bool ShowToolCalls = true,
- Action? OnShowToolCallsChanged = null,
bool IsCompact = false,
IReadOnlyList? ModelChoices = null,
Action? OnModelCleared = null,
@@ -91,9 +98,6 @@ public sealed class OpenClawComposer : Component
// model id string. Selecting it routes to OnModelCleared (tri-state clear)
// rather than OnModelChanged.
private static readonly object ClearModelTag = new();
- // Reserved id used to represent the "default / clear" model row in the rich ComboBox
- // primitive (which keys selection by string id). Cannot collide with a real SelectionId.
- private const string ClearModelId = "\u0000__default__";
// Thinking levels matching the gateway's sessions.patch thinkingLevel values.
// "medium" is the default when the session has no explicit thinkingLevel set.
@@ -116,9 +120,12 @@ public override Element Render()
// command menu that mirrors the gateway/web "type / to open" UX.
var slashMenuState = UseState<(bool Active, string Query, int Index, bool ArgsMode)>((false, "", 0, false), threadSafe: true);
+ // Surface uses the Fluent OverlayCornerRadius (8); the small controls
+ // inside the toolbar (icon buttons + inline pickers) use the tighter
+ // ControlCornerRadius (4) so they read as quiet Fluent controls.
var composerCornerRadius = new CornerRadius(8);
+ var controlCornerRadius = new CornerRadius(4);
const double composerIconSize = 16;
- const double sendButtonSize = 40;
// Version bump triggers a re-render on send so the cleared ref value
// is pushed to the TextBox control.
@@ -246,37 +253,17 @@ public override Element Render()
_ => LocalizationHelper.GetString("Chat_Composer_Placeholder_NotConnected")
};
- // ── Row 1: three compact dropdowns ─────────────────────────────
- // Grouped session picker via the reconciled rich ComboBox primitive. The primitive is
- // preserved by render path and only rebuilds its rows when the item set changes, so an
- // open dropdown survives unrelated status/thinking re-renders (the #970 regression).
- var groups = Props.AvailableChannels;
- var multipleGroups = groups.Length > 1;
- var sessionItems = new List();
- foreach (var group in groups)
- {
- if (multipleGroups)
- sessionItems.Add(new ComboItem("", group.AgentLabel, Enabled: false, IsHeader: true));
- foreach (var session in group.Sessions)
- sessionItems.Add(new ComboItem(session.Id, session.Title, Indent: multipleGroups ? 8 : 0));
- }
-
- var onChannelChanged = Props.OnChannelChanged;
- var channelCombo = ComboBox(sessionItems, Props.ChannelId ?? "", id => onChannelChanged(id))
- .Set(cb =>
- {
- cb.MinWidth = 0;
- cb.Width = double.NaN;
- cb.Height = 28;
- cb.FontSize = 11;
- cb.Padding = new Thickness(8, 0, 4, 0);
- cb.CornerRadius = composerCornerRadius;
- cb.HorizontalAlignment = HorizontalAlignment.Stretch;
- cb.VerticalAlignment = VerticalAlignment.Center;
- Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(
- cb,
- LocalizationHelper.GetString("Chat_Composer_Accessibility_Session"));
- });
+ // ── Toolbar pickers: session / model / reasoning ───────────────
+ // These replace the old ComboBoxes with quiet Fluent "text + chevron"
+ // pickers. The model and reasoning pickers are MenuFlyouts whose current
+ // selection is marked with a native checkmark (ToggleMenuItem); a
+ // disabled MenuItem renders unavailable rows. The session picker is a
+ // richer content flyout (built below, once model choices are resolved) so
+ // each session can show its associated model as a second-line subtext.
+ // All pickers carry the same SubtleButton hover/press treatment as the
+ // icon buttons — no accent, no hard-coded colors — so light/dark/
+ // high-contrast all stay correct. Menus open upward (Top) since the
+ // composer sits at the bottom of the chat surface.
// ── Model picker (provider-rich) ─────────────────────────────────
IReadOnlyList modelChoices = Props.ModelChoices is { Count: > 0 } mc
@@ -324,74 +311,180 @@ public override Element Render()
modelEntries.Add((Props.CurrentModel ?? "model", Props.CurrentModel ?? "", false, true));
}
- // Provider-rich model picker via the same reconciled primitive. Unavailable rows stay
- // visible but disabled; the default/clear row maps to a reserved id.
- var modelItems = new List(modelEntries.Count);
- string? modelSelectedId = null;
+ // Model menu: selectable rows become ToggleMenuItems (the current model
+ // is checked); explicitly unavailable rows render as disabled MenuItems
+ // so they stay visible but can't be chosen. The "Default" entry clears
+ // the session's explicit override (tri-state clear).
+ var modelMenuItems = new List(modelEntries.Count);
foreach (var entry in modelEntries)
{
- var id = ReferenceEquals(entry.Tag, ClearModelTag)
- ? ClearModelId
- : entry.Tag as string ?? "";
- modelItems.Add(new ComboItem(id, entry.Label, Enabled: entry.Selectable));
- if (entry.IsCurrent) modelSelectedId ??= id;
- }
-
- var onModelChanged = Props.OnModelChanged;
- var onModelCleared = Props.OnModelCleared;
- var modelCombo = ComboBox(modelItems, modelSelectedId, id =>
+ if (entry.Selectable)
{
- if (id == ClearModelId)
- onModelCleared?.Invoke();
- else if (!string.IsNullOrEmpty(id))
- onModelChanged(id);
- })
- .Set(cb =>
+ var tag = entry.Tag;
+ modelMenuItems.Add(ToggleMenuItem(
+ entry.Label,
+ isChecked: entry.IsCurrent,
+ onClick: () =>
+ {
+ if (ReferenceEquals(tag, ClearModelTag))
+ Props.OnModelCleared?.Invoke();
+ else if (tag is string id && !string.IsNullOrEmpty(id))
+ Props.OnModelChanged(id);
+ }));
+ }
+ else
{
- cb.MinWidth = 0;
- cb.Width = double.NaN;
- cb.Height = 28;
- cb.FontSize = 11;
- cb.Padding = new Thickness(8, 0, 4, 0);
- cb.CornerRadius = composerCornerRadius;
- cb.HorizontalAlignment = HorizontalAlignment.Stretch;
- cb.VerticalAlignment = VerticalAlignment.Center;
- cb.IsEnabled = messageOptionControlsEnabled;
- Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(
- cb,
- LocalizationHelper.GetString("Chat_Composer_Accessibility_Model"));
- })
- .VAlign(VerticalAlignment.Center);
+ modelMenuItems.Add(MenuItem(entry.Label) with { IsEnabled = false });
+ }
+ }
+ var modelMenu = MenuItems(FlyoutPlacementMode.Top, modelMenuItems.ToArray());
+
+ // Compact label for the model picker button — just the model's display
+ // name (menu rows carry the provider/context/state detail).
+ string modelPickerLabel = trackingDefault
+ ? (defaultChoice?.DisplayName ?? "Default")
+ : (currentChoice?.DisplayName ?? Props.CurrentModel ?? "Model");
var thinkingLevel = Props.CurrentThinkingLevel ?? "medium";
var thinkingIndex = Array.IndexOf(ThinkingLevelIds, thinkingLevel);
if (thinkingIndex < 0) thinkingIndex = 3; // default to "medium (default)"
- var reasoningCombo = ComboBox(ThinkingLevelLabels, thinkingIndex, idx =>
+ var reasoningMenuItems = new CoreMenuFlyoutItemBase[ThinkingLevelLabels.Length];
+ for (int i = 0; i < ThinkingLevelLabels.Length; i++)
{
- if (idx >= 0 && idx < ThinkingLevelIds.Length)
- Props.OnThinkingLevelChanged(ThinkingLevelIds[idx]);
- })
- .Set(cb =>
- {
- Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(
- cb,
- LocalizationHelper.GetString("Chat_Composer_Accessibility_Reasoning"));
- cb.MinWidth = 0;
- cb.Width = double.NaN;
- cb.Height = 28;
- cb.FontSize = 11;
- cb.Padding = new Thickness(8, 0, 4, 0);
- cb.CornerRadius = composerCornerRadius;
- cb.HorizontalAlignment = HorizontalAlignment.Stretch;
- cb.IsEnabled = messageOptionControlsEnabled;
- }).VAlign(VerticalAlignment.Center);
-
- Element dropdownsRow = Grid([GridSize.Star(1.2), GridSize.Star(), GridSize.Star(0.62)], [GridSize.Auto],
- channelCombo.Margin(0, 0, 6, 0).HAlign(HorizontalAlignment.Stretch).Grid(row: 0, column: 0),
- modelCombo.Margin(0, 0, 6, 0).HAlign(HorizontalAlignment.Stretch).Grid(row: 0, column: 1),
- reasoningCombo.HAlign(HorizontalAlignment.Stretch).Grid(row: 0, column: 2)
- ).HAlign(HorizontalAlignment.Stretch);
+ var levelIndex = i;
+ reasoningMenuItems[i] = ToggleMenuItem(
+ ThinkingLevelLabels[i],
+ isChecked: i == thinkingIndex,
+ onClick: () =>
+ {
+ if (levelIndex >= 0 && levelIndex < ThinkingLevelIds.Length)
+ Props.OnThinkingLevelChanged(ThinkingLevelIds[levelIndex]);
+ });
+ }
+ var reasoningMenu = MenuItems(FlyoutPlacementMode.Top, reasoningMenuItems);
+
+ // ── Session picker content (two-line rows) ─────────────────────────
+ // Each session row shows its title on the first line and the model it is
+ // configured to use as a muted second-line subtext, so switching model in
+ // the model picker (which patches the current session) is reflected here.
+ // A leading checkmark column marks the active session. Rows use the same
+ // subtle hover/press theme resources as the other pickers.
+ var sessionPrimaryBrush = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["TextFillColorPrimaryBrush"];
+ var sessionMutedBrush = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["TextFillColorSecondaryBrush"];
+
+ string ResolveSessionModelCaption(string? modelId, string? provider)
+ {
+ // Mirror ChatModelLabels.BuildDefaultEntryLabel's un-localized
+ // "Default" convention so the session subtext matches the model
+ // picker without introducing new (translated) resource keys.
+ if (ChatModelLabels.IsTrackingDefault(modelId))
+ return "Default";
+ var selId = ChatModelChoice.ResolveSelectionId(modelId, provider, modelChoices);
+ foreach (var c in modelChoices)
+ if (string.Equals(c.SelectionId, selId, StringComparison.Ordinal))
+ return ChatModelLabels.BuildMenuLabel(c);
+ return modelId!;
+ }
+
+ Element SessionRow((string Id, string Title, string? Model, string? ModelProvider) session, bool isCurrent)
+ {
+ var sessionId = session.Id;
+ var check = TextBlock(isCurrent ? "\uE73E" : "") // CheckMark
+ .Set(t =>
+ {
+ t.FontFamily = FluentIconCatalog.SymbolThemeFontFamily;
+ t.FontSize = 12;
+ t.Width = 16;
+ t.Foreground = sessionPrimaryBrush;
+ t.VerticalAlignment = VerticalAlignment.Center;
+ t.HorizontalAlignment = HorizontalAlignment.Center;
+ });
+ var titleBlock = TextBlock(session.Title)
+ .Set(t =>
+ {
+ t.FontSize = 14;
+ t.Foreground = sessionPrimaryBrush;
+ t.TextTrimming = TextTrimming.CharacterEllipsis;
+ t.TextWrapping = TextWrapping.NoWrap;
+ });
+ var modelBlock = TextBlock(ResolveSessionModelCaption(session.Model, session.ModelProvider))
+ .Set(t =>
+ {
+ t.FontSize = 12;
+ t.Foreground = sessionMutedBrush;
+ t.TextTrimming = TextTrimming.CharacterEllipsis;
+ t.TextWrapping = TextWrapping.NoWrap;
+ });
+ return Button(
+ HStack(8, check, VStack(2, titleBlock, modelBlock)),
+ () => Props.OnChannelChanged(sessionId))
+ .Set(b =>
+ {
+ b.HorizontalAlignment = HorizontalAlignment.Stretch;
+ b.HorizontalContentAlignment = HorizontalAlignment.Left;
+ b.Padding = new Thickness(8, 6, 8, 6);
+ b.MinWidth = 240;
+ b.CornerRadius = controlCornerRadius;
+ Microsoft.UI.Xaml.Automation.AutomationProperties.SetItemStatus(
+ b,
+ isCurrent
+ ? LocalizationHelper.GetString("Chat_Composer_Accessibility_CurrentSession")
+ : string.Empty);
+ // Close the flyout after selecting (content flyouts do not
+ // auto-dismiss on inner button clicks like a MenuFlyout does).
+ // Use a named static handler with -= then += so re-renders
+ // that reuse the button don't stack duplicate handlers.
+ b.Click -= DismissSessionFlyoutOnClick;
+ b.Click += DismissSessionFlyoutOnClick;
+ })
+ .Resources(r => r
+ .Set("ButtonBackground", new SolidColorBrush(Colors.Transparent))
+ .Set("ButtonBackgroundPointerOver", Ref("SubtleFillColorSecondaryBrush"))
+ .Set("ButtonBackgroundPressed", Ref("SubtleFillColorTertiaryBrush"))
+ .Set("ButtonBorderBrush", new SolidColorBrush(Colors.Transparent))
+ .Set("ButtonBorderBrushPointerOver", new SolidColorBrush(Colors.Transparent))
+ .Set("ButtonBorderBrushPressed", new SolidColorBrush(Colors.Transparent)));
+ }
+
+ var channelGroupsForPicker = Props.AvailableChannels;
+ var sessionRows = new List();
+ foreach (var group in channelGroupsForPicker)
+ {
+ if (channelGroupsForPicker.Length > 1)
+ sessionRows.Add(TextBlock(group.AgentLabel)
+ .Set(t =>
+ {
+ t.FontSize = 12;
+ t.FontWeight = Microsoft.UI.Text.FontWeights.SemiBold;
+ t.Foreground = sessionMutedBrush;
+ t.Margin = new Thickness(8, 6, 8, 2);
+ }));
+ foreach (var session in group.Sessions)
+ sessionRows.Add(SessionRow(session, session.Id == (Props.ChannelId ?? "")));
+ }
+ var channelFlyout = ContentFlyout(
+ Border(ScrollView(VStack(2, sessionRows.ToArray()))
+ .Set(sv =>
+ {
+ sv.MaxHeight = 320;
+ sv.HorizontalScrollMode = ScrollMode.Disabled;
+ sv.HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled;
+ }))
+ .Set(b =>
+ {
+ b.MinWidth = 240;
+ b.MaxWidth = 360;
+ }),
+ FlyoutPlacementMode.Top);
+
+
+ // Title-case the level id for the picker button (menu rows show the
+ // fuller "medium (default)" style labels).
+ var reasoningId = ThinkingLevelIds[thinkingIndex];
+ var reasoningPickerLabel = reasoningId.Length == 0
+ ? reasoningId
+ : char.ToUpperInvariant(reasoningId[0]) + reasoningId.Substring(1);
// ── Row 2: multi-line composer textbox ─────────────────────────
var recording = isRecording.Value;
@@ -1029,22 +1122,16 @@ Element RenderQueuedCard(ChatQueuedMessage message, int index)
var queuedPanel = RenderQueuedMessages();
+ // Inner text content — the border + fill now live on the unified
+ // composerSurface below, so this stays transparent/chromeless. Kept as
+ // its own element (name preserved) so the attachment preview and
+ // textbox read as one input region.
var composerInput = Border(
VStack(0, attachmentPreview, textbox)
).Set(b =>
{
- b.Background = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["TextControlBackground"];
- if (recording)
- {
- b.BorderBrush = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["AccentFillColorDefaultBrush"];
- b.BorderThickness = new Thickness(2);
- }
- else
- {
- b.BorderBrush = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["TextControlBorderBrush"];
- b.BorderThickness = new Thickness(1);
- }
- b.CornerRadius = composerCornerRadius;
+ b.Background = new SolidColorBrush(Colors.Transparent);
+ b.BorderThickness = new Thickness(0);
});
// ── Voice recording indicator: compact pill with dot, label, and mini waveform ──
@@ -1132,6 +1219,11 @@ Element RenderQueuedCard(ChatQueuedMessage message, int index)
voiceIndicator.Key = "voice-pill-hidden";
}
+ // Subtle 32×32 icon button — transparent at rest, Fluent
+ // SubtleFillColorSecondary on hover / Tertiary on press (theme
+ // resources, so light/dark/high-contrast stay correct). Radius uses the
+ // tighter ControlCornerRadius. Mirrors the design-system
+ // ComposerIconButton.
Element IconButton(string glyph, string tip, Action onClick, Brush? foreground = null)
=> Button(
TextBlock(glyph)
@@ -1146,9 +1238,10 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground =
onClick)
.Set(b =>
{
- b.Padding = new Thickness(8, 4, 8, 4);
- b.MinWidth = 32; b.MinHeight = 28;
- b.CornerRadius = composerCornerRadius;
+ b.Padding = new Thickness(0);
+ b.MinWidth = 32; b.Width = 32;
+ b.MinHeight = 32; b.Height = 32;
+ b.CornerRadius = controlCornerRadius;
})
.Resources(r => r
.Set("ButtonBackground", new SolidColorBrush(Colors.Transparent))
@@ -1160,6 +1253,79 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground =
.AutomationName(tip)
.SetToolTip(tip);
+ // Subtle inline picker — text label + chevron with the same
+ // SubtleButton hover/press treatment as the icon buttons. Reads as a
+ // quiet dropdown (no border/fill until hover), never an accent. The
+ // MenuFlyout opens upward from the toolbar. Mirrors the design-system
+ // ComposerPicker.
+ Element PickerButton(string label, string automationName, double? maxLabelWidth, FlyoutElement menu, bool enabled = true)
+ {
+ var mutedBrush = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["TextFillColorSecondaryBrush"];
+ var labelBlock = TextBlock(label)
+ .Set(t =>
+ {
+ t.FontSize = 13;
+ t.Foreground = mutedBrush;
+ t.TextTrimming = TextTrimming.CharacterEllipsis;
+ t.TextWrapping = TextWrapping.NoWrap;
+ t.VerticalAlignment = VerticalAlignment.Center;
+ if (maxLabelWidth is { } mw) t.MaxWidth = mw;
+ });
+ var chevron = TextBlock("\uE70D") // ChevronDown
+ .Set(t =>
+ {
+ t.FontFamily = FluentIconCatalog.SymbolThemeFontFamily;
+ t.FontSize = 10;
+ t.Foreground = mutedBrush;
+ t.VerticalAlignment = VerticalAlignment.Center;
+ });
+ // Fold the current selection into the accessible name so assistive
+ // tech announces ": " (e.g. "Session: "), the
+ // way the legacy ComboBox surfaced its selected item. The visible
+ // chevron button only shows the value, so without this the current
+ // selection would be silent to screen readers.
+ var accessibleName = string.IsNullOrWhiteSpace(label)
+ ? automationName
+ : $"{automationName}: {label}";
+ return Button(HStack(4, labelBlock, chevron))
+ .Set(b =>
+ {
+ b.Padding = new Thickness(8, 0, 8, 0);
+ b.MinWidth = 0;
+ b.MinHeight = 32; b.Height = 32;
+ b.CornerRadius = controlCornerRadius;
+ b.IsEnabled = enabled;
+ })
+ .Resources(r => r
+ .Set("ButtonBackground", new SolidColorBrush(Colors.Transparent))
+ .Set("ButtonBackgroundPointerOver", Ref("SubtleFillColorSecondaryBrush"))
+ .Set("ButtonBackgroundPressed", Ref("SubtleFillColorTertiaryBrush"))
+ .Set("ButtonBorderBrush", new SolidColorBrush(Colors.Transparent))
+ .Set("ButtonBorderBrushPointerOver", new SolidColorBrush(Colors.Transparent))
+ .Set("ButtonBorderBrushPressed", new SolidColorBrush(Colors.Transparent)))
+ .WithFlyout(menu)
+ .AutomationName(accessibleName)
+ .SetToolTip(automationName);
+ }
+
+ var channelPicker = PickerButton(
+ Props.ChannelLabel,
+ LocalizationHelper.GetString("Chat_Composer_Accessibility_Session"),
+ 160,
+ channelFlyout);
+ var modelPicker = PickerButton(
+ modelPickerLabel,
+ LocalizationHelper.GetString("Chat_Composer_Accessibility_Model"),
+ 180,
+ modelMenu,
+ messageOptionControlsEnabled);
+ var reasoningPicker = PickerButton(
+ reasoningPickerLabel,
+ LocalizationHelper.GetString("Chat_Composer_Accessibility_Reasoning"),
+ null,
+ reasoningMenu,
+ messageOptionControlsEnabled);
+
var attachBtn = IconButton("\uE723", LocalizationHelper.GetString("Chat_Composer_Tooltip_Attach"), () =>
{
Props.OnAttachClick?.Invoke();
@@ -1194,21 +1360,16 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground =
LocalizationHelper.GetString("Chat_Composer_Tooltip_Voice"),
startVoiceRecording);
}
- var speakerBtn = Props.OnSpeakerToggle is not null
+
+ // Speaker mute — subtle icon button (never accent). Reflects TTS mute
+ // state via the glyph and toggles it through the host. Only rendered
+ // when the host wires OnSpeakerToggle (chat surfaces with TTS).
+ Element speakerBtn = Props.OnSpeakerToggle is not null
? IconButton(
Props.IsSpeakerMuted ? "\uE74F" : "\uE767", // SpeakerMute : Speaker
Props.IsSpeakerMuted ? "Unmute" : "Mute",
() => Props.OnSpeakerToggle())
: Empty();
- // Toggle tool-call visibility. Same wrench icon in both states;
- // reduced opacity when tool calls are hidden to indicate "off"
- // without looking disabled. Tooltip clarifies the action.
- var showTools = Props.ShowToolCalls;
- var toolToggleBtn = IconButton(
- "\uE90F", // Wrench
- showTools ? "Hide tool calls & usage" : "Show tool calls & usage",
- () => Props.OnShowToolCallsChanged?.Invoke(!Props.ShowToolCalls))
- .Set(b => b.Opacity = showTools ? 1.0 : 0.55);
// ── Slash command menu (gateway commands.list discovery) ──
// Hosted in a floating Popup above the composer so the input controls
@@ -1249,16 +1410,22 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground =
}
}
- // Send button — always present so the user can queue follow-up messages
- // even while the assistant is responding.
+ // Primary action button — a single slot that shows Send when idle and
+ // the Stop button while the assistant is responding. Keeping one slot
+ // (identical geometry) means the toolbar never reflows between states.
+ // Follow-up messages can still be queued mid-turn via Enter.
var sendBrush = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["AccentFillColorDefaultBrush"];
const string sendGlyph = "\uE724";
const string stopGlyph = "\uE71A";
var hasText = hasTextState.Value || pendingAttachments.Count > 0;
var sendTooltip = LocalizationHelper.GetString("Chat_Composer_Tooltip_Send");
+ // Send is the ONE accent affordance in the composer. Its glyph sits on
+ // the accent fill, so use the Fluent "text on accent" brush (white in
+ // both themes) rather than a hard-coded color. When empty it drops to a
+ // subtle transparent button with muted glyph.
var glyphBrush = hasText
- ? (Brush)new SolidColorBrush(Colors.White)
+ ? (Brush)Microsoft.UI.Xaml.Application.Current.Resources["TextOnAccentFillColorPrimaryBrush"]
: (Brush)Microsoft.UI.Xaml.Application.Current.Resources["TextFillColorSecondaryBrush"];
var actionBtn = Button(
TextBlock(sendGlyph)
@@ -1271,9 +1438,9 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground =
sendAction
).Set(b =>
{
- b.Padding = new Thickness(10, 4, 10, 4);
- b.MinWidth = sendButtonSize + 4; b.MinHeight = sendButtonSize - 4;
- b.CornerRadius = composerCornerRadius;
+ b.Padding = new Thickness(0);
+ b.MinWidth = 40; b.MinHeight = 32; b.Height = 32;
+ b.CornerRadius = controlCornerRadius;
b.IsEnabled = isConnected;
b.Background = hasText ? sendBrush : new SolidColorBrush(Colors.Transparent);
})
@@ -1300,8 +1467,11 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground =
.AutomationName(sendTooltip)
.SetToolTip(sendTooltip);
- // Stop button — shown inline NEXT TO the send button (to its right)
- // when the assistant is responding, matching the gateway web UI pattern.
+ // Stop button — occupies the SAME action slot as Send (identical size)
+ // while the assistant is responding, so nothing in the toolbar shifts.
+ // Uses a neutral text-primary fill (theme-adaptive black/white); red is
+ // reserved for genuine error states. The glyph uses the base surface
+ // brush so it stays legible against the inverted fill in both themes.
Element stopBtn = Empty();
if (Props.TurnActive)
{
@@ -1313,19 +1483,19 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground =
t.FontFamily = FluentIconCatalog.SymbolThemeFontFamily;
t.FontSize = composerIconSize;
})
- .Foreground(new SolidColorBrush(Colors.White)),
+ .Foreground((Brush)Microsoft.UI.Xaml.Application.Current.Resources["SolidBackgroundFillColorBaseBrush"]),
Props.OnStop
).Set(b =>
{
- b.Padding = new Thickness(10, 4, 10, 4);
- b.MinWidth = sendButtonSize + 4; b.MinHeight = sendButtonSize - 4;
- b.CornerRadius = composerCornerRadius;
- b.Background = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["SystemFillColorCriticalBrush"];
+ b.Padding = new Thickness(0);
+ b.MinWidth = 40; b.MinHeight = 32; b.Height = 32;
+ b.CornerRadius = controlCornerRadius;
+ b.Background = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["TextFillColorPrimaryBrush"];
})
.Resources(r =>
{
- r.Set("ButtonBackgroundPointerOver", Ref("SystemFillColorCriticalBrush"));
- r.Set("ButtonBackgroundPressed", Ref("SystemFillColorCriticalBrush"));
+ r.Set("ButtonBackgroundPointerOver", Ref("TextFillColorSecondaryBrush"));
+ r.Set("ButtonBackgroundPressed", Ref("TextFillColorTertiaryBrush"));
r.Set("ButtonBorderBrush", new SolidColorBrush(Colors.Transparent));
r.Set("ButtonBorderBrushPointerOver", new SolidColorBrush(Colors.Transparent));
r.Set("ButtonBorderBrushPressed", new SolidColorBrush(Colors.Transparent));
@@ -1342,21 +1512,58 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground =
// history records every approval (and its decided/expired badge)
// in chronological order. See OpenClawChatTimeline.RenderPermissionEntry.
- var bottomToolbar = Grid([GridSize.Star(), GridSize.Auto], [GridSize.Auto],
- dropdownsRow
- .Margin(0, 0, 12, 0)
- .HAlign(HorizontalAlignment.Stretch)
- .Grid(row: 0, column: 0),
- (FlexRow(attachBtn, voiceCancelBtn, voiceBtn, speakerBtn, toolToggleBtn, actionBtn, stopBtn)
+ // ── Toolbar row (space-between) ────────────────────────────────
+ // LEFT cluster: Add(+) attach, then the session / model / reasoning
+ // pickers. RIGHT cluster: dictation (+ cancel while recording) and a
+ // single primary-action slot that is the accent Send when idle and the
+ // accent Stop while a turn is active. Mirrors the design-system
+ // ChatComposer toolbar.
+ var leftCluster = (FlexRow(attachBtn, channelPicker, modelPicker, reasoningPicker)
+ with { ColumnGap = 4 })
+ .HAlign(HorizontalAlignment.Left)
+ .VAlign(VerticalAlignment.Center);
+
+ // Send and Stop share one slot (same 40x32 geometry) so the toolbar
+ // never reflows when a turn starts or ends.
+ Element primaryActionBtn = Props.TurnActive ? stopBtn : actionBtn;
+ var rightCluster = (FlexRow(voiceCancelBtn, speakerBtn, voiceBtn, primaryActionBtn)
with { ColumnGap = 4 })
.HAlign(HorizontalAlignment.Right)
- .Grid(row: 0, column: 1)
+ .VAlign(VerticalAlignment.Center);
+
+ var bottomToolbar = Grid([GridSize.Star(), GridSize.Auto], [GridSize.Auto],
+ leftCluster.Grid(row: 0, column: 0),
+ rightCluster.Grid(row: 0, column: 1)
);
// ── Optional working banner above the composer ──
Element workingBanner2 = workingBanner;
- var composerCore = VStack(8, queuedPanel, composerInput, voiceIndicator, bottomToolbar.Margin(0, -8, 0, -4));
+ // Unified composer surface: one rounded (Fluent OverlayCornerRadius),
+ // 1px-line-bordered card holding the text region above the toolbar. The
+ // border turns accent while recording. Matches the design-system
+ // ChatComposer surface (radius md / 1px line / surface fill / 8 padding).
+ var composerSurface = Border(
+ VStack(8, composerInput, voiceIndicator, bottomToolbar)
+ ).Set(b =>
+ {
+ b.Background = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["TextControlBackground"];
+ if (recording)
+ {
+ b.BorderBrush = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["AccentFillColorDefaultBrush"];
+ b.BorderThickness = new Thickness(2);
+ }
+ else
+ {
+ b.BorderBrush = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["TextControlBorderBrush"];
+ b.BorderThickness = new Thickness(1);
+ }
+ b.CornerRadius = composerCornerRadius;
+ b.Padding = new Thickness(8);
+ });
+
+ // Queued messages sit above the surface (outside the input card).
+ var composerCore = VStack(8, queuedPanel, composerSurface);
// Drive the floating slash-menu popup after the tree builds so it anchors
// above the (already mounted) textbox without shifting any controls.
@@ -1369,13 +1576,27 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground =
Border(composerCore).Padding(16, 12, 16, 12)
.Set(b =>
{
- // Top divider only — mirrors Kenny's ChatShell ComposerBorder.
+ // Top divider separates the composer region from the timeline.
b.BorderThickness = new Thickness(0, 1, 0, 0);
b.BorderBrush = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["SurfaceStrokeColorDefaultBrush"];
})
);
}
+ private static void DismissSessionFlyoutOnClick(object sender, RoutedEventArgs e)
+ {
+ if (sender is FrameworkElement fe)
+ DismissOpenPopups(fe.XamlRoot);
+ }
+
+ private static void DismissOpenPopups(Microsoft.UI.Xaml.XamlRoot? root)
+ {
+ if (root is null)
+ return;
+ foreach (var popup in Microsoft.UI.Xaml.Media.VisualTreeHelper.GetOpenPopupsForXamlRoot(root))
+ popup.IsOpen = false;
+ }
+
private static double ComputeQueuedMessagesMaxHeight(bool isCompact, double? availableHeight)
{
if (isCompact)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml
index 8e717b8b0..36e3ce599 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml
@@ -127,6 +127,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs
index 1b92e7b1d..b6c369c14 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs
@@ -122,6 +122,24 @@ private void WireAutoSaveHandlers()
ScreenRecordingToggle.Toggled += (_, _) => Persist(s => s.ScreenRecordingConsentGiven = ScreenRecordingToggle.IsOn);
CameraRecordingToggle.Toggled += (_, _) => Persist(s => s.CameraRecordingConsentGiven = CameraRecordingToggle.IsOn);
+
+ // "Read responses aloud" reuses App.SetChatSpeakerMuted, which persists
+ // VoiceTtsEnabled (as the inverse of mute), updates the chat coordinator,
+ // and broadcasts the change to any open chat surface.
+ ReadResponsesAloudToggle.Toggled += (_, _) =>
+ {
+ if (_loading || CurrentApp.Settings == null) return;
+ CurrentApp.SetChatSpeakerMuted(!ReadResponsesAloudToggle.IsOn);
+ ShowSavedIndicator();
+ };
+ // "Show tool calls and usage" persists the setting and pushes the new
+ // visibility into the live chat timeline via the shared static writer.
+ ShowToolCallsToggle.Toggled += (_, _) =>
+ {
+ if (_loading || CurrentApp.Settings == null) return;
+ Persist(s => s.ShowChatToolCalls = ShowToolCallsToggle.IsOn);
+ OpenClawTray.Chat.OpenClawChatRoot.SetToolCallsVisible(ShowToolCallsToggle.IsOn);
+ };
}
private void WireCheckBox(CheckBox cb, Action mutate)
@@ -225,6 +243,11 @@ private void LoadSettings(SettingsManager settings)
ScreenRecordingToggle.IsOn = settings.ScreenRecordingConsentGiven;
CameraRecordingToggle.IsOn = settings.CameraRecordingConsentGiven;
+
+ // Chat section: "Read responses aloud" mirrors VoiceTtsEnabled (mute is
+ // its inverse). "Show tool calls and usage" mirrors ShowChatToolCalls.
+ ReadResponsesAloudToggle.IsOn = settings.VoiceTtsEnabled;
+ ShowToolCallsToggle.IsOn = settings.ShowChatToolCalls;
LoadGatewaySection(settings);
}
diff --git a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs
index e5719e824..2a11332fe 100644
--- a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs
@@ -123,6 +123,8 @@ public List UserRules
public float SttSilenceTimeout { get => _data.SttSilenceTimeout > 0 ? _data.SttSilenceTimeout : 1.5f; set => _data = _data with { SttSilenceTimeout = value }; }
/// Enable TTS playback of responses during voice sessions.
public bool VoiceTtsEnabled { get => _data.VoiceTtsEnabled; set => _data = _data with { VoiceTtsEnabled = value }; }
+ /// Show tool-call and usage chips inline in the chat timeline.
+ public bool ShowChatToolCalls { get => _data.ShowChatToolCalls; set => _data = _data with { ShowChatToolCalls = value }; }
/// Play audio feedback chimes on listen start/stop.
public bool VoiceAudioFeedback { get => _data.VoiceAudioFeedback; set => _data = _data with { VoiceAudioFeedback = value }; }
public bool NodeTtsEnabled { get => _data.NodeTtsEnabled; set => _data = _data with { NodeTtsEnabled = value }; }
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index fd5c3ff1e..cd558b235 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -3002,6 +3002,9 @@ Use one of these options:
Session
+
+ Current session
+
Model
@@ -4827,6 +4830,21 @@ Commands are blocked while sandboxing is unavailable because strict fallback blo
Notifications
+
+ Chat
+
+
+ Read responses aloud
+
+
+ Play assistant replies through text-to-speech during chat.
+
+
+ Show tool calls and usage
+
+
+ Show tool-call and token-usage details inline in the conversation.
+
Privacy
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index 1518e08c3..4bcb5c9a7 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -2955,6 +2955,9 @@ Utilisez l'une de ces options :
Conversation
+
+ Conversation actuelle
+
Modèle
@@ -4780,6 +4783,21 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo
Mes notifications
+
+ Conversation
+
+
+ Lire les réponses à voix haute
+
+
+ Lire les réponses de l’assistant à voix haute pendant la conversation.
+
+
+ Afficher les appels d’outils et l’utilisation
+
+
+ Afficher les détails des appels d’outils et de l’utilisation des jetons dans la conversation.
+
Confidentialité
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index 840fca6ce..a84df666e 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -2956,6 +2956,9 @@ Gebruik een van deze opties:
Sessie
+
+ Huidige sessie
+
Taalmodel
@@ -4781,6 +4784,21 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik
Meldingen
+
+ Gesprek
+
+
+ Antwoorden hardop voorlezen
+
+
+ Lees antwoorden van de assistent hardop voor tijdens het gesprek.
+
+
+ Toolaanroepen en gebruik weergeven
+
+
+ Details van toolaanroepen en tokengebruik inline in het gesprek weergeven.
+
Privacybeheer
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index 6a3698674..438492eb3 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -2955,6 +2955,9 @@
会话
+
+ 当前会话
+
模型
@@ -4780,6 +4783,21 @@
通知
+
+ 聊天
+
+
+ 朗读回复
+
+
+ 在聊天期间通过文本转语音播放助手回复。
+
+
+ 显示工具调用和用量
+
+
+ 在对话中内联显示工具调用和令牌用量详细信息。
+
隐私
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index 861789647..ae5ca5c69 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -2955,6 +2955,9 @@
工作階段
+
+ 目前工作階段
+
模型
@@ -4780,6 +4783,21 @@
通知
+
+ 聊天
+
+
+ 朗讀回覆
+
+
+ 在聊天期間透過文字轉語音播放助理回覆。
+
+
+ 顯示工具呼叫和用量
+
+
+ 在對話中內嵌顯示工具呼叫和權杖用量詳細資訊。
+
隱私
diff --git a/src/OpenClawTray.FunctionalUI/FunctionalUI.cs b/src/OpenClawTray.FunctionalUI/FunctionalUI.cs
index 3789ecb9a..cc74066f1 100644
--- a/src/OpenClawTray.FunctionalUI/FunctionalUI.cs
+++ b/src/OpenClawTray.FunctionalUI/FunctionalUI.cs
@@ -245,6 +245,7 @@ public sealed record MenuFlyoutItemData(string Text, Action? OnClick = null, str
public FontWeight? FontWeight { get; init; }
}
public sealed record RadioMenuFlyoutItemData(string Text, string GroupName, bool IsChecked = false, Action? OnClick = null, string? Icon = null) : MenuFlyoutItemBase;
+public sealed record ToggleMenuFlyoutItemData(string Text, bool IsChecked = false, Action? OnClick = null, string? Icon = null) : MenuFlyoutItemBase;
public sealed record MenuFlyoutSeparatorData : MenuFlyoutItemBase;
public sealed record MenuFlyoutContentElement(MenuFlyoutItemBase[] Items, FlyoutPlacementMode Placement) : FlyoutElement(Placement);
internal interface INavigationHostElement
@@ -641,6 +642,8 @@ public static MenuFlyoutItemData MenuItem(string text, Action? onClick = null, s
new(text, onClick, icon);
public static RadioMenuFlyoutItemData RadioMenuItem(string text, string groupName, bool isChecked = false, Action? onClick = null, string? icon = null) =>
new(text, groupName, isChecked, onClick, icon);
+ public static ToggleMenuFlyoutItemData ToggleMenuItem(string text, bool isChecked = false, Action? onClick = null, string? icon = null) =>
+ new(text, isChecked, onClick, icon);
public static MenuFlyoutSeparatorData MenuSeparator() => new();
public static ComponentElement Component() where TComponent : Component, new() =>
new(typeof(TComponent), null);
@@ -943,10 +946,12 @@ internal sealed class UiRenderer(Action requestRender)
private readonly Dictionary _controls = new();
private readonly Dictionary _components = new();
private readonly Dictionary _contentFlyouts = new();
+ private readonly Dictionary _menuFlyouts = new();
private readonly HashSet _mountedPaths = new();
private readonly HashSet _visitedControlPaths = new();
private readonly HashSet _visitedComponentKeys = new();
private readonly HashSet _visitedContentFlyoutPaths = new();
+ private readonly HashSet _visitedMenuFlyoutPaths = new();
private readonly HashSet _visitedVirtualStackPaths = new();
private readonly Dictionary _virtualStackOwnedPathPrefixes = new();
@@ -958,6 +963,7 @@ public UIElement Render(Element element, string path, List effects)
_visitedControlPaths.Clear();
_visitedComponentKeys.Clear();
_visitedContentFlyoutPaths.Clear();
+ _visitedMenuFlyoutPaths.Clear();
_visitedVirtualStackPaths.Clear();
var rendered = RenderElement(element, path, effects);
@@ -976,6 +982,7 @@ public void Dispose()
_components.Clear();
_controls.Clear();
_contentFlyouts.Clear();
+ _menuFlyouts.Clear();
_mountedPaths.Clear();
_visitedVirtualStackPaths.Clear();
_virtualStackOwnedPathPrefixes.Clear();
@@ -1531,6 +1538,15 @@ private void PruneUnvisitedCachedSubtree(string prefix)
_contentFlyouts.Remove(path);
}
+ foreach (var (path, flyout) in _menuFlyouts
+ .Where(pair => IsPathAtOrBelow(pair.Key, prefix) && !_visitedMenuFlyoutPaths.Contains(pair.Key))
+ .ToArray())
+ {
+ flyout.Hide();
+ flyout.Items.Clear();
+ _menuFlyouts.Remove(path);
+ }
+
foreach (var (path, cachedControl) in _controls
.Where(pair => IsPathAtOrBelow(pair.Key, prefix) && !_visitedControlPaths.Contains(pair.Key))
.OrderByDescending(pair => pair.Key.Length)
@@ -1561,6 +1577,15 @@ private void RemoveCachedSubtree(string prefix)
_contentFlyouts.Remove(path);
}
+ foreach (var (path, flyout) in _menuFlyouts
+ .Where(pair => IsPathAtOrBelow(pair.Key, prefix))
+ .ToArray())
+ {
+ flyout.Hide();
+ flyout.Items.Clear();
+ _menuFlyouts.Remove(path);
+ }
+
foreach (var (path, cachedControl) in _controls
.Where(pair => IsPathAtOrBelow(pair.Key, prefix))
.OrderByDescending(pair => pair.Key.Length)
@@ -1678,7 +1703,7 @@ private FlyoutBase CreateFlyout(FlyoutElement element, string path, List
return element switch
{
ContentFlyoutElement content => CreateContentFlyout(content, path, effects),
- MenuFlyoutContentElement menu => CreateMenuFlyout(menu),
+ MenuFlyoutContentElement menu => CreateMenuFlyout(menu, path),
_ => throw new NotSupportedException($"Unsupported functional UI flyout: {element.GetType().Name}")
};
}
@@ -1698,7 +1723,7 @@ private Flyout CreateContentFlyout(ContentFlyoutElement element, string path, Li
// and stops the open popup from being torn apart mid-interaction.
if (!_contentFlyouts.TryGetValue(path, out var flyout))
{
- flyout = new Flyout();
+ flyout = new Flyout { FlyoutPresenterStyle = TightFlyoutPresenterStyle() };
_contentFlyouts[path] = flyout;
}
@@ -1712,6 +1737,28 @@ private Flyout CreateContentFlyout(ContentFlyoutElement element, string path, Li
return flyout;
}
+ // A FlyoutPresenter style that strips the default padding / min-width so a
+ // content flyout reads as tight as a MenuFlyout (whose MenuFlyoutPresenter
+ // uses ~0,4,0,4 padding and no wide min-width). The flyout content supplies
+ // its own inner sizing. Cached so the identical Style is shared across all
+ // content flyouts.
+ private static Style? _tightFlyoutPresenterStyle;
+
+ private static Style TightFlyoutPresenterStyle()
+ {
+ if (_tightFlyoutPresenterStyle is not null)
+ return _tightFlyoutPresenterStyle;
+
+ var style = new Style(typeof(FlyoutPresenter));
+ // Equal inset on all four sides so the floating rows have the same
+ // breathing room top/bottom as they do left/right (matches the modern
+ // WinUI menu-flyout look). The rows supply their own inner padding.
+ style.Setters.Add(new Setter(Control.PaddingProperty, new Thickness(4, 4, 4, 4)));
+ style.Setters.Add(new Setter(FrameworkElement.MinWidthProperty, 0.0));
+ _tightFlyoutPresenterStyle = style;
+ return style;
+ }
+
private void PruneUnvisitedPaths()
{
foreach (var path in _virtualStackOwnedPathPrefixes.Keys.ToArray())
@@ -1739,6 +1786,16 @@ private void PruneUnvisitedPaths()
_contentFlyouts.Remove(path);
}
+ foreach (var (path, flyout) in _menuFlyouts.ToArray())
+ {
+ if (_visitedMenuFlyoutPaths.Contains(path) || IsOwnedByVirtualStack(path))
+ continue;
+
+ flyout.Hide();
+ flyout.Items.Clear();
+ _menuFlyouts.Remove(path);
+ }
+
foreach (var (path, control) in _controls.ToArray())
{
if (_visitedControlPaths.Contains(path) || IsOwnedByVirtualStack(path))
@@ -1770,9 +1827,17 @@ private static bool IsPathAtOrBelow(string path, string prefix) =>
|| path.StartsWith(prefix + ".", StringComparison.Ordinal)
|| path.StartsWith(prefix + ":", StringComparison.Ordinal);
- private static MenuFlyout CreateMenuFlyout(MenuFlyoutContentElement element)
+ private MenuFlyout CreateMenuFlyout(MenuFlyoutContentElement element, string path)
{
- var flyout = new MenuFlyout { Placement = element.Placement };
+ _visitedMenuFlyoutPaths.Add(path);
+ if (!_menuFlyouts.TryGetValue(path, out var flyout))
+ {
+ flyout = new MenuFlyout();
+ _menuFlyouts[path] = flyout;
+ }
+
+ flyout.Placement = element.Placement;
+ flyout.Items.Clear();
foreach (var item in element.Items)
flyout.Items.Add(CreateMenuFlyoutItem(item));
return flyout;
@@ -1805,6 +1870,15 @@ private static Microsoft.UI.Xaml.Controls.MenuFlyoutItemBase CreateMenuFlyoutIte
};
radioItem.Click += RadioMenuFlyoutItemClick;
return radioItem;
+ case ToggleMenuFlyoutItemData data:
+ var toggleItem = new ToggleMenuFlyoutItem
+ {
+ Text = data.Text,
+ IsChecked = data.IsChecked,
+ Tag = data
+ };
+ toggleItem.Click += ToggleMenuFlyoutItemClick;
+ return toggleItem;
case MenuFlyoutSeparatorData:
return new MenuFlyoutSeparator();
default:
@@ -2210,5 +2284,18 @@ private static void RadioMenuFlyoutItemClick(object sender, RoutedEventArgs e)
if (sender is RadioMenuFlyoutItem { Tag: RadioMenuFlyoutItemData data })
data.OnClick?.Invoke();
}
+
+ private static void ToggleMenuFlyoutItemClick(object sender, RoutedEventArgs e)
+ {
+ if (sender is ToggleMenuFlyoutItem { Tag: ToggleMenuFlyoutItemData data } item)
+ {
+ // The menu is rebuilt from props on every re-render, so the rendered
+ // IsChecked is the single source of truth. Undo WinUI's automatic
+ // click-toggle here so a stale check can't linger if the click does
+ // not produce a re-render (e.g. re-selecting the current item).
+ item.IsChecked = data.IsChecked;
+ data.OnClick?.Invoke();
+ }
+ }
}
}
diff --git a/tests/OpenClaw.Tray.Tests/ChatTimelineRenderIdentityContractTests.cs b/tests/OpenClaw.Tray.Tests/ChatTimelineRenderIdentityContractTests.cs
index 40e8ed252..9eec8379f 100644
--- a/tests/OpenClaw.Tray.Tests/ChatTimelineRenderIdentityContractTests.cs
+++ b/tests/OpenClaw.Tray.Tests/ChatTimelineRenderIdentityContractTests.cs
@@ -97,9 +97,14 @@ public void Composer_DisablesMessageOptionDropdownsWhileTurnOrPendingQueueSendIs
Assert.Contains("var messageOptionControlsEnabled = !Props.MessageOptionsDisabled;", composer);
Assert.Contains("MessageOptionsDisabled: turnActiveOverride || hasPendingQueuedSend", root);
Assert.Contains("message.SendState is ChatQueuedMessageSendState.Queued or ChatQueuedMessageSendState.Sending", root);
- Assert.Equal(2, Regex.Matches(composer, "IsEnabled = messageOptionControlsEnabled").Count);
+ // The redesigned pickers are subtle menu-flyout buttons whose disabled
+ // state is centralized in the PickerButton helper (b.IsEnabled = enabled).
+ // The model and reasoning pickers pass the gate; the session/channel
+ // picker is intentionally left enabled while a turn is active.
+ Assert.Contains("b.IsEnabled = enabled;", composer);
+ Assert.Equal(2, Regex.Matches(composer, @"messageOptionControlsEnabled\);").Count);
Assert.DoesNotMatch(
- new Regex(@"var\s+channelCombo[\s\S]*?IsEnabled\s*=\s*messageOptionControlsEnabled[\s\S]*?//\s+── Model picker", RegexOptions.Multiline),
+ new Regex(@"var\s+channelPicker\s*=\s*PickerButton\([\s\S]*?messageOptionControlsEnabled[\s\S]*?var\s+modelPicker", RegexOptions.Multiline),
composer);
}
diff --git a/tests/OpenClaw.Tray.Tests/ChatToolCallsToggleContractTests.cs b/tests/OpenClaw.Tray.Tests/ChatToolCallsToggleContractTests.cs
index 6cf37879e..af15e71bf 100644
--- a/tests/OpenClaw.Tray.Tests/ChatToolCallsToggleContractTests.cs
+++ b/tests/OpenClaw.Tray.Tests/ChatToolCallsToggleContractTests.cs
@@ -5,21 +5,41 @@ namespace OpenClaw.Tray.Tests;
public sealed class ChatToolCallsToggleContractTests
{
[Fact]
- public void ProductionTimeline_HonorsComposerToolCallVisibilityToggle()
+ public void ProductionTimeline_HonorsSettingsToolCallVisibilityToggle()
{
var root = Read("src", "OpenClaw.Tray.WinUI", "Chat", "OpenClawChatRoot.cs");
var composer = Read("src", "OpenClaw.Tray.WinUI", "Chat", "OpenClawComposer.cs");
var timeline = Read("src", "OpenClaw.Tray.WinUI", "Chat", "OpenClawChatTimeline.cs");
+ var settings = Read("src", "OpenClaw.Tray.WinUI", "Pages", "SettingsPage.xaml.cs");
+ var app = Read("src", "OpenClaw.Tray.WinUI", "App.xaml.cs");
+ // Root still owns the shared tool-call visibility state and feeds it to
+ // the timeline (independent of the composer).
Assert.Contains("ShowToolCalls: showToolCalls.Value", root);
Assert.Contains("ToolCallsCollapseVersion: toolCallsCollapseVersion.Value", root);
- Assert.Contains("OnShowToolCallsChanged: visible =>", root);
Assert.Contains("UseState(s_showToolCalls", root);
Assert.Contains("UseState(s_toolCallsCollapseVersion", root);
- Assert.Contains("s_showToolCalls = visible", root);
Assert.Contains("ToolCallsVisibilityChanged", root);
- Assert.Contains("bool ShowToolCalls = true", composer);
- Assert.Contains("Action? OnShowToolCallsChanged = null", composer);
+
+ // The single writer now lives on the root as a public static, invoked by
+ // Settings and by startup seeding — no longer a composer callback.
+ Assert.Contains("public static void SetToolCallsVisible(bool", root);
+ Assert.Contains("s_showToolCalls = visible", root);
+ Assert.DoesNotContain("OnShowToolCallsChanged", root);
+
+ // The composer no longer hosts the tool-call toggle at all.
+ Assert.DoesNotContain("ShowToolCalls", composer);
+ Assert.DoesNotContain("OnShowToolCallsChanged", composer);
+
+ // Settings drives it: persists the setting and pushes it into the live
+ // timeline via the static writer.
+ Assert.Contains("OpenClawTray.Chat.OpenClawChatRoot.SetToolCallsVisible", settings);
+ Assert.Contains("ShowChatToolCalls", settings);
+
+ // Startup seeds visibility from the persisted setting.
+ Assert.Contains("SetToolCallsVisible(_settings.ShowChatToolCalls)", app);
+
+ // Timeline still consumes the props from the root.
Assert.Matches(new Regex(@"var\s+showToolCalls\s*=\s*Props\.ShowToolCalls\s*;"), timeline);
Assert.Matches(new Regex(@"var\s+collapseToolChipsVersion\s*=\s*Props\.ToolCallsCollapseVersion\s*;"), timeline);
}
diff --git a/tests/OpenClaw.Tray.Tests/ComposerSessionPickerTests.cs b/tests/OpenClaw.Tray.Tests/ComposerSessionPickerTests.cs
index 022ee8bdb..96e55c671 100644
--- a/tests/OpenClaw.Tray.Tests/ComposerSessionPickerTests.cs
+++ b/tests/OpenClaw.Tray.Tests/ComposerSessionPickerTests.cs
@@ -1,10 +1,10 @@
namespace OpenClaw.Tray.Tests;
///
-/// Source-contract guards for the composer pickers. These assert the composer builds its session
-/// and model dropdowns through the reconciled FunctionalUI ComboBox primitive rather than
-/// hand-rolling a native ComboBox inside a setter — the imperative escape hatch that caused
-/// the #970 "dropdown slams shut on status render" regression.
+/// Source-contract guards for the composer pickers. These assert the redesigned composer uses
+/// declarative FunctionalUI flyouts rather than hand-rolling a native ComboBox inside a
+/// setter — the imperative escape hatch that caused the #970 "dropdown slams shut on status
+/// render" regression.
///
public sealed class ComposerSessionPickerTests
{
@@ -16,21 +16,23 @@ private static string ComposerSource() => File.ReadAllText(Path.Combine(
"OpenClawComposer.cs"));
[Fact]
- public void SessionPicker_UsesReconciledItemComboBoxPrimitive()
+ public void SessionPicker_UsesDeclarativeContentFlyout()
{
var composer = ComposerSource();
- Assert.Contains("var sessionItems = new List();", composer);
- Assert.Contains("ComboBox(sessionItems, Props.ChannelId ?? \"\"", composer);
+ Assert.Contains("var sessionRows = new List();", composer);
+ Assert.Contains("var channelFlyout = ContentFlyout(", composer);
+ Assert.Contains("var channelPicker = PickerButton(", composer);
}
[Fact]
- public void ModelPicker_UsesReconciledItemComboBoxPrimitive()
+ public void ModelPicker_UsesDeclarativeMenuFlyout()
{
var composer = ComposerSource();
- Assert.Contains("ComboBox(modelItems, modelSelectedId", composer);
- Assert.Contains("if (id == ClearModelId)", composer);
+ Assert.Contains("var modelMenu = MenuItems(", composer);
+ Assert.Contains("var modelPicker = PickerButton(", composer);
+ Assert.Contains("ToggleMenuItem(", composer);
}
[Fact]
@@ -42,5 +44,6 @@ public void Composer_DoesNotHandRollNativePickersOrSnapshots()
Assert.DoesNotContain("border.Child = cb;", composer);
Assert.DoesNotContain("SessionPickerSnapshot", composer);
Assert.DoesNotContain("Native(", composer);
+ Assert.DoesNotContain("ComboBox(sessionItems", composer);
}
}
diff --git a/tests/OpenClaw.Tray.Tests/FunctionalUiModifierResetContractTests.cs b/tests/OpenClaw.Tray.Tests/FunctionalUiModifierResetContractTests.cs
index 50286f3bd..81a28563f 100644
--- a/tests/OpenClaw.Tray.Tests/FunctionalUiModifierResetContractTests.cs
+++ b/tests/OpenClaw.Tray.Tests/FunctionalUiModifierResetContractTests.cs
@@ -40,6 +40,7 @@ public void UiRenderer_PrunesUnvisitedCachesAfterRender()
Assert.Contains("private readonly HashSet _visitedControlPaths = new();", functionalUi);
Assert.Contains("private readonly HashSet _visitedComponentKeys = new();", functionalUi);
Assert.Contains("private readonly HashSet _visitedContentFlyoutPaths = new();", functionalUi);
+ Assert.Contains("private readonly HashSet _visitedMenuFlyoutPaths = new();", functionalUi);
Assert.Contains("PruneUnvisitedPaths();", functionalUi);
Assert.Contains("component.Context.RunEffectCleanups();", functionalUi);
Assert.Contains("flyout.Hide();", functionalUi);
@@ -49,6 +50,17 @@ public void UiRenderer_PrunesUnvisitedCachesAfterRender()
Assert.Contains("_controls.Remove(path);", functionalUi);
}
+ [Fact]
+ public void UiRenderer_CachesMenuFlyoutsByRenderPath()
+ {
+ var functionalUi = Read("src", "OpenClawTray.FunctionalUI", "FunctionalUI.cs");
+
+ Assert.Contains("private readonly Dictionary _menuFlyouts = new();", functionalUi);
+ Assert.Contains("MenuFlyoutContentElement menu => CreateMenuFlyout(menu, path)", functionalUi);
+ Assert.Contains("if (!_menuFlyouts.TryGetValue(path, out var flyout))", functionalUi);
+ Assert.Contains("flyout.Items.Clear();", functionalUi);
+ }
+
private static string Read(params string[] parts)
=> File.ReadAllText(Path.Combine(new[] { TestRepositoryPaths.GetRepositoryRoot() }.Concat(parts).ToArray()));
}
diff --git a/tests/OpenClaw.Tray.UITests/SessionTitleBehaviorProofTests.cs b/tests/OpenClaw.Tray.UITests/SessionTitleBehaviorProofTests.cs
index ab5a25eb3..374241237 100644
--- a/tests/OpenClaw.Tray.UITests/SessionTitleBehaviorProofTests.cs
+++ b/tests/OpenClaw.Tray.UITests/SessionTitleBehaviorProofTests.cs
@@ -122,9 +122,13 @@ private string WaitForSelectedSession(string expectedRouteTitle)
var composerCondition = new PropertyCondition(
AutomationElement.AutomationIdProperty,
"ChatComposerInput");
- var sessionSelectorCondition = new AndCondition(
- new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.ComboBox),
- new PropertyCondition(AutomationElement.NameProperty, "Session"));
+ // The redesigned session selector is a subtle menu-flyout Button (not a
+ // ComboBox). Its accessible name folds in the current selection as
+ // "Session: ", replacing the legacy ComboBox's
+ // SelectionPattern. Match on the route title so the exact field-label
+ // prefix/separator format stays free to change.
+ var buttonCondition = new PropertyCondition(
+ AutomationElement.ControlTypeProperty, ControlType.Button);
string? selectedRouteTitle = null;
WaitUntil(() =>
@@ -133,21 +137,19 @@ private string WaitForSelectedSession(string expectedRouteTitle)
if (hub.FindFirst(TreeScope.Descendants, composerCondition) is null)
return false;
- var selector = hub.FindFirst(TreeScope.Descendants, sessionSelectorCondition);
- if (selector is null
- || !selector.TryGetCurrentPattern(SelectionPattern.Pattern, out var pattern)
- || pattern is not SelectionPattern selection)
+ var buttons = hub.FindAll(TreeScope.Descendants, buttonCondition);
+ for (var i = 0; i < buttons.Count; i++)
{
- return false;
+ var name = buttons[i].Current.Name;
+ if (!string.IsNullOrEmpty(name)
+ && name.Contains(expectedRouteTitle, StringComparison.Ordinal))
+ {
+ selectedRouteTitle = name;
+ return true;
+ }
}
- selectedRouteTitle = selection.Current.GetSelection()
- .Select(item => item.Current.Name)
- .SingleOrDefault();
- return string.Equals(
- selectedRouteTitle,
- expectedRouteTitle,
- StringComparison.Ordinal);
+ return false;
}, $"chat Session selector to choose '{expectedRouteTitle}'");
return Assert.IsType(selectedRouteTitle);