From 6220c85133723b5b4bebb2dfbab44b7ae4c90648 Mon Sep 17 00:00:00 2001 From: Karen Lai <7976322+karkarl@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:34:40 -0700 Subject: [PATCH] Apply design-system composer to app chat UI Rebase the final composer redesign onto current main, preserve the FunctionalUI picker fix, stabilize menu flyouts across rerenders, localize Chat settings, and retain current speaker and Send/Stop behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fef25ef9-fa78-442c-beb0-d0a6f9a8fb92 --- src/OpenClaw.Shared/SettingsData.cs | 2 + src/OpenClaw.Tray.WinUI/App.xaml.cs | 3 + src/OpenClaw.Tray.WinUI/Chat/ChannelGroup.cs | 4 +- .../Chat/OpenClawChatRoot.cs | 28 +- .../Chat/OpenClawComposer.cs | 537 ++++++++++++------ .../Pages/SettingsPage.xaml | 50 ++ .../Pages/SettingsPage.xaml.cs | 23 + .../Services/SettingsManager.cs | 2 + .../Strings/en-us/Resources.resw | 18 + .../Strings/fr-fr/Resources.resw | 18 + .../Strings/nl-nl/Resources.resw | 18 + .../Strings/zh-cn/Resources.resw | 18 + .../Strings/zh-tw/Resources.resw | 18 + src/OpenClawTray.FunctionalUI/FunctionalUI.cs | 95 +++- ...ChatTimelineRenderIdentityContractTests.cs | 9 +- .../ChatToolCallsToggleContractTests.cs | 30 +- .../ComposerSessionPickerTests.cs | 23 +- .../FunctionalUiModifierResetContractTests.cs | 12 + .../SessionTitleBehaviorProofTests.cs | 32 +- 19 files changed, 735 insertions(+), 205 deletions(-) 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 @@ </Grid> </Border> + <!-- ═════════ Chat ═════════ --> + <TextBlock x:Uid="SettingsPage_Chat" Text="Chat" Style="{StaticResource BodyStrongTextBlockStyle}" + Margin="{StaticResource SectionHeaderMargin}"/> + + <Border Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" + BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" + BorderThickness="1" CornerRadius="8" Padding="{StaticResource SettingsRowPadding}"> + <Grid ColumnSpacing="14"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="Auto"/> + </Grid.ColumnDefinitions> + <StackPanel Grid.Column="0" VerticalAlignment="Center" Spacing="2"> + <TextBlock x:Name="ReadResponsesAloudLabel" x:Uid="SettingsRow_ReadAloud_Header" + Text="Read responses aloud" Style="{StaticResource BodyTextBlockStyle}"/> + <TextBlock x:Uid="SettingsRow_ReadAloud_Description" + Text="Play assistant replies through text-to-speech during chat." + Style="{StaticResource CaptionTextBlockStyle}" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap"/> + </StackPanel> + <ToggleSwitch Grid.Column="1" x:Name="ReadResponsesAloudToggle" MinWidth="0" OnContent="" OffContent="" + AutomationProperties.AutomationId="SettingsPageReadAloud" + AutomationProperties.LabeledBy="{Binding ElementName=ReadResponsesAloudLabel}"/> + </Grid> + </Border> + + <Border Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" + BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" + BorderThickness="1" CornerRadius="8" Padding="{StaticResource SettingsRowPadding}"> + <Grid ColumnSpacing="14"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="Auto"/> + </Grid.ColumnDefinitions> + <StackPanel Grid.Column="0" VerticalAlignment="Center" Spacing="2"> + <TextBlock x:Name="ShowToolCallsLabel" x:Uid="SettingsRow_ShowToolCalls_Header" + Text="Show tool calls and usage" Style="{StaticResource BodyTextBlockStyle}"/> + <TextBlock x:Uid="SettingsRow_ShowToolCalls_Description" + Text="Show tool-call and token-usage details inline in the conversation." + Style="{StaticResource CaptionTextBlockStyle}" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap"/> + </StackPanel> + <ToggleSwitch Grid.Column="1" x:Name="ShowToolCallsToggle" MinWidth="0" OnContent="" OffContent="" + AutomationProperties.AutomationId="SettingsPageShowToolCalls" + AutomationProperties.LabeledBy="{Binding ElementName=ShowToolCallsLabel}"/> + </Grid> + </Border> + <!-- ═════════ Notifications ═════════ --> <TextBlock x:Uid="SettingsPage_Notifications" Text="Notifications" Style="{StaticResource BodyStrongTextBlockStyle}" Margin="{StaticResource SectionHeaderMargin}"/> 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<bool> 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<OpenClaw.Shared.UserNotificationRule> UserRules public float SttSilenceTimeout { get => _data.SttSilenceTimeout > 0 ? _data.SttSilenceTimeout : 1.5f; set => _data = _data with { SttSilenceTimeout = value }; } /// <summary>Enable TTS playback of responses during voice sessions.</summary> public bool VoiceTtsEnabled { get => _data.VoiceTtsEnabled; set => _data = _data with { VoiceTtsEnabled = value }; } + /// <summary>Show tool-call and usage chips inline in the chat timeline.</summary> + public bool ShowChatToolCalls { get => _data.ShowChatToolCalls; set => _data = _data with { ShowChatToolCalls = value }; } /// <summary>Play audio feedback chimes on listen start/stop.</summary> 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: <data name="Chat_Composer_Accessibility_Session" xml:space="preserve"> <value>Session</value> </data> + <data name="Chat_Composer_Accessibility_CurrentSession" xml:space="preserve"> + <value>Current session</value> + </data> <data name="Chat_Composer_Accessibility_Model" xml:space="preserve"> <value>Model</value> </data> @@ -4827,6 +4830,21 @@ Commands are blocked while sandboxing is unavailable because strict fallback blo <data name="SettingsPage_Notifications.Text" xml:space="preserve"> <value>Notifications</value> </data> + <data name="SettingsPage_Chat.Text" xml:space="preserve"> + <value>Chat</value> + </data> + <data name="SettingsRow_ReadAloud_Header.Text" xml:space="preserve"> + <value>Read responses aloud</value> + </data> + <data name="SettingsRow_ReadAloud_Description.Text" xml:space="preserve"> + <value>Play assistant replies through text-to-speech during chat.</value> + </data> + <data name="SettingsRow_ShowToolCalls_Header.Text" xml:space="preserve"> + <value>Show tool calls and usage</value> + </data> + <data name="SettingsRow_ShowToolCalls_Description.Text" xml:space="preserve"> + <value>Show tool-call and token-usage details inline in the conversation.</value> + </data> <data name="SettingsPage_Privacy.Text" xml:space="preserve"> <value>Privacy</value> </data> 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 : <data name="Chat_Composer_Accessibility_Session" xml:space="preserve"> <value>Conversation</value> </data> + <data name="Chat_Composer_Accessibility_CurrentSession" xml:space="preserve"> + <value>Conversation actuelle</value> + </data> <data name="Chat_Composer_Accessibility_Model" xml:space="preserve"> <value>Modèle</value> </data> @@ -4780,6 +4783,21 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo <data name="SettingsPage_Notifications.Text" xml:space="preserve"> <value>Mes notifications</value> </data> + <data name="SettingsPage_Chat.Text" xml:space="preserve"> + <value>Conversation</value> + </data> + <data name="SettingsRow_ReadAloud_Header.Text" xml:space="preserve"> + <value>Lire les réponses à voix haute</value> + </data> + <data name="SettingsRow_ReadAloud_Description.Text" xml:space="preserve"> + <value>Lire les réponses de l’assistant à voix haute pendant la conversation.</value> + </data> + <data name="SettingsRow_ShowToolCalls_Header.Text" xml:space="preserve"> + <value>Afficher les appels d’outils et l’utilisation</value> + </data> + <data name="SettingsRow_ShowToolCalls_Description.Text" xml:space="preserve"> + <value>Afficher les détails des appels d’outils et de l’utilisation des jetons dans la conversation.</value> + </data> <data name="SettingsPage_Privacy.Text" xml:space="preserve"> <value>Confidentialité</value> </data> 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: <data name="Chat_Composer_Accessibility_Session" xml:space="preserve"> <value>Sessie</value> </data> + <data name="Chat_Composer_Accessibility_CurrentSession" xml:space="preserve"> + <value>Huidige sessie</value> + </data> <data name="Chat_Composer_Accessibility_Model" xml:space="preserve"> <value>Taalmodel</value> </data> @@ -4781,6 +4784,21 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik <data name="SettingsPage_Notifications.Text" xml:space="preserve"> <value>Meldingen</value> </data> + <data name="SettingsPage_Chat.Text" xml:space="preserve"> + <value>Gesprek</value> + </data> + <data name="SettingsRow_ReadAloud_Header.Text" xml:space="preserve"> + <value>Antwoorden hardop voorlezen</value> + </data> + <data name="SettingsRow_ReadAloud_Description.Text" xml:space="preserve"> + <value>Lees antwoorden van de assistent hardop voor tijdens het gesprek.</value> + </data> + <data name="SettingsRow_ShowToolCalls_Header.Text" xml:space="preserve"> + <value>Toolaanroepen en gebruik weergeven</value> + </data> + <data name="SettingsRow_ShowToolCalls_Description.Text" xml:space="preserve"> + <value>Details van toolaanroepen en tokengebruik inline in het gesprek weergeven.</value> + </data> <data name="SettingsPage_Privacy.Text" xml:space="preserve"> <value>Privacybeheer</value> </data> 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 @@ <data name="Chat_Composer_Accessibility_Session" xml:space="preserve"> <value>会话</value> </data> + <data name="Chat_Composer_Accessibility_CurrentSession" xml:space="preserve"> + <value>当前会话</value> + </data> <data name="Chat_Composer_Accessibility_Model" xml:space="preserve"> <value>模型</value> </data> @@ -4780,6 +4783,21 @@ <data name="SettingsPage_Notifications.Text" xml:space="preserve"> <value>通知</value> </data> + <data name="SettingsPage_Chat.Text" xml:space="preserve"> + <value>聊天</value> + </data> + <data name="SettingsRow_ReadAloud_Header.Text" xml:space="preserve"> + <value>朗读回复</value> + </data> + <data name="SettingsRow_ReadAloud_Description.Text" xml:space="preserve"> + <value>在聊天期间通过文本转语音播放助手回复。</value> + </data> + <data name="SettingsRow_ShowToolCalls_Header.Text" xml:space="preserve"> + <value>显示工具调用和用量</value> + </data> + <data name="SettingsRow_ShowToolCalls_Description.Text" xml:space="preserve"> + <value>在对话中内联显示工具调用和令牌用量详细信息。</value> + </data> <data name="SettingsPage_Privacy.Text" xml:space="preserve"> <value>隐私</value> </data> 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 @@ <data name="Chat_Composer_Accessibility_Session" xml:space="preserve"> <value>工作階段</value> </data> + <data name="Chat_Composer_Accessibility_CurrentSession" xml:space="preserve"> + <value>目前工作階段</value> + </data> <data name="Chat_Composer_Accessibility_Model" xml:space="preserve"> <value>模型</value> </data> @@ -4780,6 +4783,21 @@ <data name="SettingsPage_Notifications.Text" xml:space="preserve"> <value>通知</value> </data> + <data name="SettingsPage_Chat.Text" xml:space="preserve"> + <value>聊天</value> + </data> + <data name="SettingsRow_ReadAloud_Header.Text" xml:space="preserve"> + <value>朗讀回覆</value> + </data> + <data name="SettingsRow_ReadAloud_Description.Text" xml:space="preserve"> + <value>在聊天期間透過文字轉語音播放助理回覆。</value> + </data> + <data name="SettingsRow_ShowToolCalls_Header.Text" xml:space="preserve"> + <value>顯示工具呼叫和用量</value> + </data> + <data name="SettingsRow_ShowToolCalls_Description.Text" xml:space="preserve"> + <value>在對話中內嵌顯示工具呼叫和權杖用量詳細資訊。</value> + </data> <data name="SettingsPage_Privacy.Text" xml:space="preserve"> <value>隱私</value> </data> 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<TComponent>() where TComponent : Component, new() => new(typeof(TComponent), null); @@ -943,10 +946,12 @@ internal sealed class UiRenderer(Action requestRender) private readonly Dictionary<string, UIElement> _controls = new(); private readonly Dictionary<string, Component> _components = new(); private readonly Dictionary<string, Flyout> _contentFlyouts = new(); + private readonly Dictionary<string, MenuFlyout> _menuFlyouts = new(); private readonly HashSet<string> _mountedPaths = new(); private readonly HashSet<string> _visitedControlPaths = new(); private readonly HashSet<string> _visitedComponentKeys = new(); private readonly HashSet<string> _visitedContentFlyoutPaths = new(); + private readonly HashSet<string> _visitedMenuFlyoutPaths = new(); private readonly HashSet<string> _visitedVirtualStackPaths = new(); private readonly Dictionary<string, string[]> _virtualStackOwnedPathPrefixes = new(); @@ -958,6 +963,7 @@ public UIElement Render(Element element, string path, List<Action> 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<Action> 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<bool>? 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; /// <summary> -/// 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 <c>ComboBox</c> 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 <c>ComboBox</c> inside a +/// setter — the imperative escape hatch that caused the #970 "dropdown slams shut on status +/// render" regression. /// </summary> 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<ComboItem>();", composer); - Assert.Contains("ComboBox(sessionItems, Props.ChannelId ?? \"\"", composer); + Assert.Contains("var sessionRows = new List<Element?>();", 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<string> _visitedControlPaths = new();", functionalUi); Assert.Contains("private readonly HashSet<string> _visitedComponentKeys = new();", functionalUi); Assert.Contains("private readonly HashSet<string> _visitedContentFlyoutPaths = new();", functionalUi); + Assert.Contains("private readonly HashSet<string> _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<string, MenuFlyout> _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: <route title>", 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<string>(selectedRouteTitle);