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);