diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs index 6c0ceec..496e879 100644 --- a/src/OpenClaw.Shared/Models.cs +++ b/src/OpenClaw.Shared/Models.cs @@ -1522,12 +1522,27 @@ public class AgentEventInfo public string FormattedTime => Timestamp.ToString("HH:mm:ss.fff"); - public string StreamUpper => Stream.ToUpperInvariant(); + /// Resolved event kind — for "item" stream events, uses data.kind instead. + public string ResolvedStream + { + get + { + var s = Stream.ToLowerInvariant(); + if (s == "item" && Data.ValueKind == JsonValueKind.Object && + Data.TryGetProperty("kind", out var k)) + { + return k.GetString()?.ToLowerInvariant() ?? s; + } + return s; + } + } + + public string StreamUpper => ResolvedStream.ToUpperInvariant(); /// Color hex for stream badge (used by UI to create brush). - public string BadgeColorHex => Stream.ToLowerInvariant() switch + public string BadgeColorHex => ResolvedStream switch { - "tool" => "#FFDC781E", // Orange + "tool" => "#FFB45D3A", // Burnt sienna "assistant" => "#FF28A050", // Green "error" => "#FFC83232", // Red "lifecycle" => "#FF3C78C8", // Blue @@ -1546,17 +1561,23 @@ public class AgentEventInfo if (!string.IsNullOrEmpty(Summary)) return Summary; try { - var s = Stream.ToLowerInvariant(); + var s = ResolvedStream; if (s == "tool" && Data.ValueKind == JsonValueKind.Object) { var name = Data.TryGetProperty("name", out var n) ? n.GetString() : null; + var title = Data.TryGetProperty("title", out var ti) ? ti.GetString() : null; var phase = Data.TryGetProperty("phase", out var p) ? p.GetString() : null; - if (name != null) return phase != null ? $"🔧 {name} ({phase})" : $"🔧 {name}"; + var status = Data.TryGetProperty("status", out var st) ? st.GetString() : null; + // Prefer title (richer) over just name + if (title != null) + return phase != null ? $"🔧 {title} ({phase})" : $"🔧 {title}"; + if (name != null) + return phase != null ? $"🔧 {name} ({phase})" : $"🔧 {name}"; } if (s == "assistant" && Data.ValueKind == JsonValueKind.Object) { var text = Data.TryGetProperty("text", out var t) ? t.GetString() : null; - if (text != null) return text.Length > 120 ? text[..120] + "…" : text; + if (text != null) return text.Length > 300 ? text[..300] + "…" : text; } if (s == "error" && Data.ValueKind == JsonValueKind.Object) { @@ -1566,8 +1587,11 @@ public class AgentEventInfo } if (s == "lifecycle" && Data.ValueKind == JsonValueKind.Object) { - var state = Data.TryGetProperty("state", out var st) ? st.GetString() : null; - if (state != null) return $"⚡ {state}"; + var state = Data.TryGetProperty("state", out var st) ? st.GetString() + : Data.TryGetProperty("livenessState", out var ls) ? ls.GetString() : null; + var phase = Data.TryGetProperty("phase", out var ph) ? ph.GetString() : null; + if (state != null) + return phase != null ? $"⚡ {state} ({phase})" : $"⚡ {state}"; } } catch { } @@ -1577,18 +1601,51 @@ public class AgentEventInfo public bool HasSummary => !string.IsNullOrEmpty(SummaryLine); + /// Full assistant message text (no truncation), for expanded view. + public string? FullAssistantText + { + get + { + if (ResolvedStream != "assistant" || Data.ValueKind != JsonValueKind.Object) return null; + try { return Data.TryGetProperty("text", out var t) ? t.GetString() : null; } + catch { return null; } + } + } + + /// Whether this event is an assistant stream (expanded view shows full text instead of JSON). + public bool IsAssistantStream => ResolvedStream == "assistant"; + + /// Whether to show the raw DataJson section. Hidden for streams where SummaryLine is sufficient. + public bool ShowDataJson + { + get + { + var s = ResolvedStream; + if (s is "assistant" or "error" or "lifecycle") return false; + return true; + } + } + + // UI-only state for expand/collapse (not serialized) + [System.Text.Json.Serialization.JsonIgnore] + public bool IsExpanded { get; set; } + + private string? _cachedDataJson; + public string DataJson { get { + if (_cachedDataJson != null) return _cachedDataJson; try { - return JsonSerializer.Serialize(Data, new JsonSerializerOptions { WriteIndented = true }); + _cachedDataJson = JsonSerializer.Serialize(Data, new JsonSerializerOptions { WriteIndented = true }); } catch { - return Data.ToString() ?? "{}"; + _cachedDataJson = Data.ToString() ?? "{}"; } + return _cachedDataJson; } } } diff --git a/src/OpenClaw.Shared/WebSocketClientBase.cs b/src/OpenClaw.Shared/WebSocketClientBase.cs index aecde0a..97a5813 100644 --- a/src/OpenClaw.Shared/WebSocketClientBase.cs +++ b/src/OpenClaw.Shared/WebSocketClientBase.cs @@ -251,6 +251,10 @@ public abstract class WebSocketClientBase : IDisposable while (!_disposed && !_cts.Token.IsCancellationRequested && ShouldAutoReconnect()) { var delay = BackoffMs[Math.Min(_reconnectAttempts, BackoffMs.Length - 1)]; + // Add 0-25% jitter to prevent thundering herd when multiple clients + // (operator + node) reconnect on the same schedule + var jitter = Random.Shared.Next(0, delay / 4); + delay += jitter; _reconnectAttempts++; _logger.Warn($"{ClientRole} reconnecting in {delay}ms (attempt {_reconnectAttempts})"); RaiseStatusChanged(ConnectionStatus.Connecting); diff --git a/src/OpenClaw.Shared/WindowsNodeClient.cs b/src/OpenClaw.Shared/WindowsNodeClient.cs index a9d4acd..c88b824 100644 --- a/src/OpenClaw.Shared/WindowsNodeClient.cs +++ b/src/OpenClaw.Shared/WindowsNodeClient.cs @@ -30,6 +30,10 @@ public class WindowsNodeClient : WebSocketClientBase private bool _isPaired; // Bridges the gap between an approval event and the next hello-ok when the gateway omits auth.deviceToken. private bool _pairingApprovedAwaitingReconnect; + // Persists across disconnect/error so ShouldAutoReconnect can block reconnect + // even after OnDisconnected clears _isPendingApproval. + private volatile bool _pairingBlocked; + private volatile bool _rateLimited; private readonly string _gatewayToken; private readonly string? _bootstrapToken; @@ -277,6 +281,7 @@ public class WindowsNodeClient : WebSocketClientBase _isPendingApproval = true; _isPaired = false; + _pairingBlocked = true; _pairingApprovedAwaitingReconnect = false; _logger.Info($"[NODE] Pairing requested for this device via {eventType}"); @@ -310,6 +315,7 @@ public class WindowsNodeClient : WebSocketClientBase { _isPendingApproval = false; _isPaired = true; + _pairingBlocked = false; // Allow reconnect after approval _pairingApprovedAwaitingReconnect = true; PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( @@ -603,6 +609,7 @@ public class WindowsNodeClient : WebSocketClientBase PublishGatewaySelf(GatewaySelfInfo.FromHelloOk(payload)); var reconnectingAfterApproval = _pairingApprovedAwaitingReconnect; _isConnected = true; + _rateLimited = false; // Clear transient rate-limit on successful connect ResetReconnectAttempts(); // Extract node ID if returned @@ -654,6 +661,7 @@ public class WindowsNodeClient : WebSocketClientBase { _isPendingApproval = true; _isPaired = false; + _pairingBlocked = true; _logger.Info("Not yet paired - check 'openclaw devices list' for pending approval"); _logger.Info($"To approve, run: openclaw devices approve {_deviceIdentity.DeviceId}"); PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( @@ -717,6 +725,7 @@ public class WindowsNodeClient : WebSocketClientBase _isPendingApproval = true; _isPaired = false; + _pairingBlocked = true; _pairingApprovedAwaitingReconnect = false; var detail = !string.IsNullOrWhiteSpace(pairingRequestId) @@ -731,6 +740,18 @@ public class WindowsNodeClient : WebSocketClientBase return; } + // Rate-limit / terminal auth errors — stop reconnecting + if (error.Contains("too many failed", StringComparison.OrdinalIgnoreCase) || + error.Contains("rate limit", StringComparison.OrdinalIgnoreCase) || + error.Contains("origin not allowed", StringComparison.OrdinalIgnoreCase) || + error.Contains("token mismatch", StringComparison.OrdinalIgnoreCase)) + { + _rateLimited = true; + _logger.Warn($"[NODE] Terminal auth error; stopping reconnect. Error: {error}"); + RaiseStatusChanged(ConnectionStatus.Error); + return; + } + _logger.Error($"Node registration failed: {error} (code: {errorCode})"); RaiseStatusChanged(ConnectionStatus.Error); } @@ -997,6 +1018,20 @@ public class WindowsNodeClient : WebSocketClientBase GatewaySelfUpdated?.Invoke(this, info); } + protected override bool ShouldAutoReconnect() + { + // Don't reconnect while awaiting pairing approval — each reconnect + // generates a new pairing request on the gateway, causing a storm. + // _pairingBlocked survives OnDisconnected (which clears _isPendingApproval). + if (_pairingBlocked) + return false; + + if (_rateLimited) + return false; + + return true; + } + protected override void OnDisconnected() { _isConnected = false; diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 0b3968d..3593081 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -86,6 +86,8 @@ public partial class App : Application private DevicePairingListInfo? _lastDevicePairList; private ModelsListInfo? _lastModelsList; private PresenceEntry[]? _lastPresence; + private readonly List _agentEventsCache = new(); + private const int MaxAppAgentEvents = 400; private UpdateCommandCenterInfo _lastUpdateInfo = BuildInitialUpdateInfo(); private DateTime _lastCheckTime = DateTime.Now; private DateTime _lastUsageActivityLogUtc = DateTime.MinValue; @@ -883,6 +885,7 @@ public partial class App : Application _lastNodePairList = null; _lastDevicePairList = null; _lastModelsList = null; + _agentEventsCache.Clear(); UpdateTrayIcon(); _hubWindow?.UpdateStatus(_currentStatus); } @@ -1491,19 +1494,30 @@ public partial class App : Application return; } - if (string.IsNullOrWhiteSpace(_settings.Token)) + // Need either a regular token or a bootstrap token to connect + var effectiveToken = _settings.Token; + if (string.IsNullOrWhiteSpace(effectiveToken)) { - Logger.Info("Gateway token not configured — skipping operator client initialization"); - return; + if (useBootstrapHandoffAuth && !string.IsNullOrWhiteSpace(_settings.BootstrapToken)) + { + // Bootstrap-only flow (setup code / QR): use bootstrap token for initial pairing + effectiveToken = _settings.BootstrapToken; + } + else + { + Logger.Info("Gateway token not configured — skipping operator client initialization"); + return; + } } // Unsubscribe from old client if exists UnsubscribeGatewayEvents(); + _gatewayClient?.Dispose(); _lastGatewaySelf = null; _gatewayClient = new OpenClawGatewayClient( gatewayUrl, - _settings.Token, + effectiveToken, new AppLogger(), useBootstrapHandoffAuth); _gatewayClient.SetUserRules(_settings.UserRules.Count > 0 ? _settings.UserRules : null); @@ -1944,6 +1958,19 @@ public partial class App : Application if (_hubWindow != null && !_hubWindow.IsClosed) _hubWindow.LastAuthError = null; } + + // Clear stale data when disconnected so tray menu doesn't show old sessions/nodes + if (status == ConnectionStatus.Disconnected || status == ConnectionStatus.Error) + { + _lastSessions = Array.Empty(); + _lastChannels = Array.Empty(); + _lastNodes = Array.Empty(); + _lastNodePairList = null; + _lastDevicePairList = null; + _lastModelsList = null; + _lastGatewaySelf = null; + } + UpdateTrayIcon(); _dispatcherQueue?.TryEnqueue(UpdateStatusDetailWindow); @@ -2252,7 +2279,13 @@ public partial class App : Application private void OnAgentEventReceived(object? sender, AgentEventInfo evt) { - _dispatcherQueue?.TryEnqueue(() => _hubWindow?.UpdateAgentEvent(evt)); + _dispatcherQueue?.TryEnqueue(() => + { + _agentEventsCache.Insert(0, evt); + if (_agentEventsCache.Count > MaxAppAgentEvents) + _agentEventsCache.RemoveRange(MaxAppAgentEvents, _agentEventsCache.Count - MaxAppAgentEvents); + _hubWindow?.UpdateAgentEvent(evt); + }); } private void OnNodePairListUpdated(object? sender, PairingListInfo data) @@ -2497,6 +2530,7 @@ public partial class App : Application _hubWindow.OpenDashboardAction = OpenDashboard; _hubWindow.CheckForUpdatesAction = () => _ = CheckForUpdatesUserInitiatedAsync(); _hubWindow.QuickSendAction = () => ShowQuickSend(); + _hubWindow.OpenSetupAction = () => _ = ShowOnboardingAsync(); _hubWindow.ConnectAction = () => { InitializeGatewayClient(); @@ -2518,6 +2552,7 @@ public partial class App : Application { ReconnectGateway(); }; + _hubWindow.ClearAppAgentEventsCache = () => _agentEventsCache.Clear(); if (_nodeService != null) { _hubWindow.NodeIsConnected = _nodeService.IsConnected; @@ -2574,6 +2609,7 @@ public partial class App : Application if (_lastPresence != null) _hubWindow.UpdatePresence(_lastPresence); if (_lastGatewaySelf != null) _hubWindow.UpdateGatewaySelf(_lastGatewaySelf); if (_lastAgentsList.HasValue) _hubWindow.UpdateAgentsList(_lastAgentsList.Value); + if (_agentEventsCache.Count > 0) _hubWindow.SeedAgentEvents(_agentEventsCache); } private void ShowSettings() diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/ConnectionPage.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/ConnectionPage.cs index 4355a8f..1d59a76 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/ConnectionPage.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/ConnectionPage.cs @@ -136,14 +136,14 @@ public sealed class ConnectionPage : Component void OnSetupCodeChanged(string code) { - setSetupCode(code); if (string.IsNullOrWhiteSpace(code)) return; var result = SetupCodeDecoder.Decode(code); if (!result.Success) { - // Not a valid setup code — user might be still typing + // Not a valid setup code — user might be still typing. + // Don't call setSetupCode here to avoid re-render that steals focus. if (code.Length > 2048) Logger.Warn("[Connection] Setup code rejected: exceeds 2048 character limit"); else @@ -151,6 +151,8 @@ public sealed class ConnectionPage : Component return; } + // Valid setup code decoded — now update state (will re-render) + setSetupCode(code); if (result.Url != null) { setUrl(result.Url); @@ -159,7 +161,8 @@ public sealed class ConnectionPage : Component if (result.Token != null) { setToken(result.Token); - Props.Settings.Token = result.Token; + // Bootstrap token goes to BootstrapToken only — it's single-use for pairing. + // Don't save as Settings.Token (causes reconnect storms on restart). Props.Settings.BootstrapToken = result.Token; } setStatusMsg($"✅ {LocalizationHelper.GetString("Onboarding_Connection_StatusDecoded")}"); @@ -205,7 +208,13 @@ public sealed class ConnectionPage : Component async void TestConnection() { Props.Settings.GatewayUrl = url; - Props.Settings.Token = token; + // Only save to Settings.Token if the user entered a manual token, + // not a decoded bootstrap token (which belongs in BootstrapToken only). + if (string.IsNullOrWhiteSpace(Props.Settings.BootstrapToken) || + !string.Equals(token, Props.Settings.BootstrapToken, StringComparison.Ordinal)) + { + Props.Settings.Token = token; + } // When SSH mode, start the managed tunnel before health-checking the local URL. if (mode == ConnectionMode.Ssh) @@ -473,40 +482,14 @@ public sealed class ConnectionPage : Component catch { /* clipboard unavailable — ignore */ } } - // Setup code row: TextField + Paste + QR buttons (Grid keeps the field expanding) + // Setup code row: TextField + Paste + QR buttons cardChildren.Add( Grid(["1*", "Auto", "Auto"], ["Auto"], TextField(setupCode, OnSetupCodeChanged, placeholder: LocalizationHelper.GetString("Onboarding_Connection_SetupCodePlaceholder"), header: LocalizationHelper.GetString("Onboarding_Connection_SetupCode")) - .OnGotFocus((sender, _) => - { - if (sender is Microsoft.UI.Xaml.Controls.TextBox tb && string.IsNullOrEmpty(tb.Text)) - { - try - { - var content = global::Windows.ApplicationModel.DataTransfer.Clipboard.GetContent(); - if (content.Contains(global::Windows.ApplicationModel.DataTransfer.StandardDataFormats.Text)) - { - var task = content.GetTextAsync(); - task.Completed = (op, status) => - { - if (status == global::Windows.Foundation.AsyncStatus.Completed) - { - var text = op.GetResults(); - tb.DispatcherQueue.TryEnqueue(() => - { - tb.Text = text; - OnSetupCodeChanged(text); - }); - } - }; - } - } - catch { } - } - }) - .Grid(row: 0, column: 0), + .Grid(row: 0, column: 0) + .Set(tb => Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(tb, "OnboardingSetupCode")), Button(LocalizationHelper.GetString("Onboarding_Connection_PasteSetup"), PasteSetupCode) .VAlign(VerticalAlignment.Bottom) .Margin(6, 0, 0, 0) diff --git a/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml index 1c90ccf..2af0c32 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml @@ -48,10 +48,19 @@ + + + - @@ -59,31 +68,47 @@ - - - - + + + + + + + + + + - - - + + + FontSize="13" TextWrapping="Wrap" MaxLines="3" + Margin="0,6,0,0"/> - - + + + + + + + + + + + diff --git a/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml.cs index 7fc3640..11088f4 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml.cs @@ -60,6 +60,19 @@ public sealed partial class AgentEventsPage : Page public void AddEvent(AgentEventInfo evt) { + // Deduplicate by RunId + Seq + if (_allEvents.Any(e => e.RunId == evt.RunId && e.Seq == evt.Seq)) + return; + + // For assistant events, replace earlier streaming chunks with the latest one + // (each chunk contains the full accumulated text, so only the latest matters) + if (evt.Stream.Equals("assistant", StringComparison.OrdinalIgnoreCase)) + { + _allEvents.RemoveAll(e => + e.Stream.Equals("assistant", StringComparison.OrdinalIgnoreCase) && + e.RunId == evt.RunId && e.Seq < evt.Seq); + } + _allEvents.Insert(0, evt); if (_allEvents.Count > MaxEvents) _allEvents.RemoveRange(MaxEvents, _allEvents.Count - MaxEvents); @@ -100,9 +113,9 @@ public sealed partial class AgentEventsPage : Page filtered = filtered.Where(e => e.SessionKey != null && e.SessionKey.StartsWith($"agent:{_agentIdFilter}:", StringComparison.OrdinalIgnoreCase)); - // Filter by stream type + // Filter by stream type (use ResolvedStream so "item" events with kind:"tool" match the Tool filter) if (_activeFilter != "all") - filtered = filtered.Where(e => e.Stream.Equals(_activeFilter, StringComparison.OrdinalIgnoreCase)); + filtered = filtered.Where(e => e.ResolvedStream.Equals(_activeFilter, StringComparison.OrdinalIgnoreCase)); var list = filtered.ToList(); EventsList.ItemsSource = list; @@ -121,30 +134,96 @@ public sealed partial class AgentEventsPage : Page private void EventsList_ContainerContentChanging(ListViewBase sender, ContainerContentChangingEventArgs args) { - if (args.Item is AgentEventInfo evt && args.ItemContainer?.ContentTemplateRoot is Grid grid) + if (args.Item is not AgentEventInfo evt || args.ItemContainer?.ContentTemplateRoot is not Grid grid) + return; + + // Row 0: header Grid with badge (col 0), timestamp (col 1), chevron (col 3) + if (grid.Children[0] is Grid headerGrid && headerGrid.Children[0] is Border badge) { - // Find the first Border in the first StackPanel (the badge) - if (grid.Children[0] is StackPanel headerPanel && headerPanel.Children[0] is Border badge) + var hex = evt.BadgeColorHex; + try { - var hex = evt.BadgeColorHex; - try - { - var a = Convert.ToByte(hex[1..3], 16); - var r = Convert.ToByte(hex[3..5], 16); - var g = Convert.ToByte(hex[5..7], 16); - var b = Convert.ToByte(hex[7..9], 16); - badge.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush( - Microsoft.UI.ColorHelper.FromArgb(a, r, g, b)); - } - catch - { - badge.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Gray); - } + var r = Convert.ToByte(hex[3..5], 16); + var g = Convert.ToByte(hex[5..7], 16); + var b = Convert.ToByte(hex[7..9], 16); + var color = Microsoft.UI.ColorHelper.FromArgb(255, r, g, b); + badge.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush( + Microsoft.UI.ColorHelper.FromArgb(40, r, g, b)); + if (badge.Child is TextBlock badgeText) + badgeText.Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(color); } - // Hide summary row if empty - if (grid.Children.Count > 1 && grid.Children[1] is TextBlock summaryBlock) + catch { - summaryBlock.Visibility = evt.HasSummary ? Visibility.Visible : Visibility.Collapsed; + badge.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush( + Microsoft.UI.ColorHelper.FromArgb(40, 100, 100, 100)); + } + + // Update chevron glyph based on model state + if (headerGrid.Children.Count > 2 && headerGrid.Children[2] is FontIcon chevron) + chevron.Glyph = evt.IsExpanded ? "\uE70E" : "\uE70D"; + } + + // Row 1: summary + if (grid.Children.Count > 1 && grid.Children[1] is TextBlock summaryBlock) + { + summaryBlock.Visibility = evt.HasSummary ? Visibility.Visible : Visibility.Collapsed; + if (evt.IsAssistantStream) + { + // Swap between truncated summary and full text + summaryBlock.Text = evt.IsExpanded ? (evt.FullAssistantText ?? evt.SummaryLine) : evt.SummaryLine; + summaryBlock.MaxLines = evt.IsExpanded ? 0 : 3; + } + else + { + summaryBlock.Text = evt.SummaryLine; + summaryBlock.MaxLines = evt.IsExpanded ? 0 : 3; + } + } + + // Row 2: detail panel — only for streams that still need raw JSON + if (grid.Children.Count > 2 && grid.Children[2] is Grid detailGrid) + { + if (!evt.ShowDataJson) + { + // Assistant/error/lifecycle events show enough context in the summary row. + detailGrid.Visibility = Visibility.Collapsed; + } + else + { + detailGrid.Visibility = evt.IsExpanded ? Visibility.Visible : Visibility.Collapsed; + } + } + } + + private void EventsList_ItemClick(object sender, ItemClickEventArgs e) + { + if (e.ClickedItem is not AgentEventInfo evt) return; + evt.IsExpanded = !evt.IsExpanded; + + // Update the visual container + if (sender is ListView listView) + { + var container = listView.ContainerFromItem(e.ClickedItem) as ListViewItem; + if (container?.ContentTemplateRoot is Grid grid) + { + // Update chevron + if (grid.Children[0] is Grid headerGrid + && headerGrid.Children.Count > 2 && headerGrid.Children[2] is FontIcon chevron) + chevron.Glyph = evt.IsExpanded ? "\uE70E" : "\uE70D"; + + // Update summary text and MaxLines + if (grid.Children.Count > 1 && grid.Children[1] is TextBlock summaryBlock) + { + summaryBlock.Text = evt.IsAssistantStream && evt.IsExpanded + ? (evt.FullAssistantText ?? evt.SummaryLine) + : evt.SummaryLine; + summaryBlock.MaxLines = evt.IsExpanded ? 0 : 3; + } + + // Toggle detail panel only for streams where raw JSON is still useful. + if (grid.Children.Count > 2 && grid.Children[2] is Grid detailGrid) + detailGrid.Visibility = (evt.IsExpanded && evt.ShowDataJson) + ? Visibility.Visible : Visibility.Collapsed; } } } diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml index 44c610f..99ab53e 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml @@ -100,7 +100,7 @@ -