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.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 0b3968d..8d81117 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); } @@ -2252,7 +2255,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) @@ -2518,6 +2527,7 @@ public partial class App : Application { ReconnectGateway(); }; + _hubWindow.ClearAppAgentEventsCache = () => _agentEventsCache.Clear(); if (_nodeService != null) { _hubWindow.NodeIsConnected = _nodeService.IsConnected; @@ -2574,6 +2584,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/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/Windows/HubWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs index b62997d..355504f 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs @@ -399,9 +399,34 @@ public sealed partial class HubWindow : WindowEx private readonly System.Collections.Generic.List _agentEvents = new(); public System.Collections.Generic.IReadOnlyList LastAgentEvents => _agentEvents; + /// Called by App to also clear its own agent event cache when Clear is invoked. + public Action? ClearAppAgentEventsCache { get; set; } + public void ClearAgentEvents() { - DispatcherQueue?.TryEnqueue(() => _agentEvents.Clear()); + DispatcherQueue?.TryEnqueue(() => + { + _agentEvents.Clear(); + ClearAppAgentEventsCache?.Invoke(); + }); + } + + /// Seed the hub's agent event cache from App-level cache (deduplicates by RunId+Seq). + public void SeedAgentEvents(IReadOnlyList appCache) + { + DispatcherQueue?.TryEnqueue(() => + { + var existingKeys = new System.Collections.Generic.HashSet<(string, int)>( + _agentEvents.Select(e => (e.RunId, e.Seq))); + foreach (var evt in appCache) + { + if (!existingKeys.Contains((evt.RunId, evt.Seq))) + { + _agentEvents.Add(evt); + if (_agentEvents.Count >= MaxAgentEvents) break; + } + } + }); } public void UpdateAgentEvent(AgentEventInfo evt)