Agent events UI cleanup and redesign (#284)

Overhaul the Agent Events page with persistent caching, event deduplication, resolved stream classification, expandable event cards, and clearer summaries/badges.\n\nIncludes a maintainer follow-up to ensure assistant events expand to full text and raw JSON remains hidden for assistant/error/lifecycle streams.\n\nCo-authored-by: Christine Yan <christineyan@microsoft.com>\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Christine Yan 2026-05-06 00:16:11 -04:00 committed by GitHub
parent b356697e02
commit 584a19fadd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 251 additions and 54 deletions

View File

@ -1522,12 +1522,27 @@ public class AgentEventInfo
public string FormattedTime => Timestamp.ToString("HH:mm:ss.fff");
public string StreamUpper => Stream.ToUpperInvariant();
/// <summary>Resolved event kind — for "item" stream events, uses data.kind instead.</summary>
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();
/// <summary>Color hex for stream badge (used by UI to create brush).</summary>
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);
/// <summary>Full assistant message text (no truncation), for expanded view.</summary>
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; }
}
}
/// <summary>Whether this event is an assistant stream (expanded view shows full text instead of JSON).</summary>
public bool IsAssistantStream => ResolvedStream == "assistant";
/// <summary>Whether to show the raw DataJson section. Hidden for streams where SummaryLine is sufficient.</summary>
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;
}
}
}

View File

@ -86,6 +86,8 @@ public partial class App : Application
private DevicePairingListInfo? _lastDevicePairList;
private ModelsListInfo? _lastModelsList;
private PresenceEntry[]? _lastPresence;
private readonly List<AgentEventInfo> _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()

View File

@ -48,10 +48,19 @@
<ListView x:Name="EventsList" Grid.Row="2"
SelectionMode="None"
Visibility="Collapsed"
IsItemClickEnabled="True"
ItemClick="EventsList_ItemClick"
ContainerContentChanging="EventsList_ContainerContentChanging">
<ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Padding" Value="0,1"/>
<Setter Property="Margin" Value="0"/>
</Style>
</ListView.ItemContainerStyle>
<ListView.ItemTemplate>
<DataTemplate>
<Grid Padding="12,8" Margin="0,1" CornerRadius="4"
<Grid Padding="16,10,16,12" Margin="0,2" CornerRadius="6"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
@ -59,31 +68,47 @@
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Row 0: stream badge + timestamp + session -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8">
<Border CornerRadius="4" Padding="6,2">
<TextBlock Text="{Binding StreamUpper}" FontSize="11" FontWeight="SemiBold"
Foreground="White"/>
<!-- Row 0: badge + timestamp + chevron -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" CornerRadius="4" Padding="8,2,8,4" VerticalAlignment="Center">
<TextBlock Text="{Binding StreamUpper}" FontSize="10" FontWeight="SemiBold"
Foreground="White" VerticalAlignment="Center"/>
</Border>
<TextBlock Text="{Binding FormattedTime}" VerticalAlignment="Center"
FontSize="11" FontFamily="Consolas"
<TextBlock Grid.Column="1" Text="{Binding FormattedTime}" VerticalAlignment="Center"
FontSize="11" FontFamily="Consolas" Margin="8,0,0,0"
Foreground="{ThemeResource TextFillColorTertiaryBrush}"/>
<TextBlock Text="{Binding RunId}" VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorTertiaryBrush}"
MaxWidth="120" TextTrimming="CharacterEllipsis"/>
</StackPanel>
<FontIcon Grid.Column="3" Glyph="&#xE70D;" FontSize="10" VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorTertiaryBrush}"/>
</Grid>
<!-- Row 1: Summary line -->
<TextBlock Grid.Row="1" Text="{Binding SummaryLine}"
FontWeight="SemiBold" FontSize="12"
Margin="0,4,0,0" TextTrimming="CharacterEllipsis"/>
FontSize="13" TextWrapping="Wrap" MaxLines="3"
Margin="0,6,0,0"/>
<!-- Row 2: Data JSON (collapsed by default, expandable) -->
<TextBlock Grid.Row="2" Text="{Binding DataJson}" TextWrapping="Wrap"
MaxLines="6" FontFamily="Consolas" FontSize="11"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Margin="0,4,0,0" IsTextSelectionEnabled="True"/>
<!-- Row 2: Expandable detail (hidden by default) -->
<Grid Grid.Row="2" Visibility="Collapsed" Margin="0,8,0,0">
<!-- Assistant: full text, same formatting as summary -->
<TextBlock TextWrapping="Wrap" FontSize="13"
IsTextSelectionEnabled="True" Visibility="Collapsed"/>
<!-- Non-assistant: RunId + JSON -->
<StackPanel Spacing="4">
<TextBlock Text="{Binding RunId}"
FontSize="11" FontFamily="Consolas"
Foreground="{ThemeResource TextFillColorTertiaryBrush}"/>
<TextBlock Text="{Binding DataJson}" TextWrapping="Wrap"
FontFamily="Consolas" FontSize="11"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
IsTextSelectionEnabled="True"/>
</StackPanel>
</Grid>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>

View File

@ -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;
}
}
}

View File

@ -399,9 +399,34 @@ public sealed partial class HubWindow : WindowEx
private readonly System.Collections.Generic.List<AgentEventInfo> _agentEvents = new();
public System.Collections.Generic.IReadOnlyList<AgentEventInfo> LastAgentEvents => _agentEvents;
/// <summary>Called by App to also clear its own agent event cache when Clear is invoked.</summary>
public Action? ClearAppAgentEventsCache { get; set; }
public void ClearAgentEvents()
{
DispatcherQueue?.TryEnqueue(() => _agentEvents.Clear());
DispatcherQueue?.TryEnqueue(() =>
{
_agentEvents.Clear();
ClearAppAgentEventsCache?.Invoke();
});
}
/// <summary>Seed the hub's agent event cache from App-level cache (deduplicates by RunId+Seq).</summary>
public void SeedAgentEvents(IReadOnlyList<AgentEventInfo> 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)