From 82161106155e706266f61419d34ab1bb8f96d344 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 14:14:39 +0700 Subject: [PATCH 01/13] feat(goose): preserve decoded value-change revisions and selected-source retention (1/3) --- src/ProcessBus.Core/Models/GooseMessageItem.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ProcessBus.Core/Models/GooseMessageItem.cs b/src/ProcessBus.Core/Models/GooseMessageItem.cs index 1271ad77..8f25e1db 100644 --- a/src/ProcessBus.Core/Models/GooseMessageItem.cs +++ b/src/ProcessBus.Core/Models/GooseMessageItem.cs @@ -20,6 +20,9 @@ public sealed class GooseMessageItem public DateTime LastSeenUtc { get; set; } public string ValuesText { get; set; } = "N/A"; public string ChangedSummaryText { get; set; } = "N/A"; + // Decoder-owned revision survives retransmissions between UI snapshots. + public long ValueChangeRevision { get; set; } + public DateTime? LastValueChangeUtc { get; set; } public string StatusText { get; set; } = "Detected"; public IReadOnlyList DataValues { get; set; } = Array.Empty(); } From 8131663b0907d699bb7ea9521232c52270042a92 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 14:14:42 +0700 Subject: [PATCH 02/13] feat(goose): preserve decoded value-change revisions and selected-source retention (2/3) --- .../Analysis/RawProcessBusAnalyzer.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs index 6129f936..d390b32e 100644 --- a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs +++ b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs @@ -501,6 +501,9 @@ private void ObserveGoose(GoosePacket packet) var previous = state.Item; AnnotateGooseChanges(item, previous); + var valueChanged = item.DataValues.Any(value => value.IsChanged); + item.ValueChangeRevision = previous.ValueChangeRevision + (valueChanged ? 1 : 0); + item.LastValueChangeUtc = valueChanged ? item.LastSeenUtc : previous.LastValueChangeUtc; if (previous.StNum != item.StNum || previous.SqNum != item.SqNum) { @@ -760,7 +763,12 @@ private static void AnnotateGooseChanges(GooseMessageItem current, GooseMessageI return; } - if (previous is null || previous.DataValues.Count == 0) + if (previous is null || previous.DataValues.Count == 0 || + current.ConfRev != previous.ConfRev || + !string.Equals(current.DataSet, previous.DataSet, StringComparison.Ordinal) || + current.DataValues.Count != previous.DataValues.Count || + current.DataValues.Any(value => !previous.DataValues.Any(old => + old.Index == value.Index && string.Equals(old.Type, value.Type, StringComparison.Ordinal)))) { // The first observation has no comparative baseline. Calling every // value "changed" fabricates a state transition not seen on the wire. @@ -2891,6 +2899,8 @@ public GooseMessageItem Clone() LastSeenUtc = Item.LastSeenUtc, ValuesText = Item.ValuesText, ChangedSummaryText = Item.ChangedSummaryText, + ValueChangeRevision = Item.ValueChangeRevision, + LastValueChangeUtc = Item.LastValueChangeUtc, StatusText = Item.StatusText, DataValues = Item.DataValues .Select(x => new GooseDatasetValueItem From 59aa17dd50ac8dd1220e4f896d05ee97ff3ecc3f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 14:14:45 +0700 Subject: [PATCH 03/13] feat(goose): preserve decoded value-change revisions and selected-source retention (3/3) --- .../Presentation/GoosePublisherCatalog.cs | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs b/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs index 7aa91f4e..ad97a377 100644 --- a/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs +++ b/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs @@ -45,6 +45,18 @@ public GoosePublisherRow(GoosePublisherKey key, GooseMessageItem current) public bool IsTest => Current.IsTest; public uint StNum => Current.StNum; public uint SqNum => Current.SqNum; + public bool HasRecentValueChange { get; private set; } + public string LastValueChangeText => Current.LastValueChangeUtc.HasValue + ? $"DataSet changed {Current.LastValueChangeUtc.Value.ToLocalTime():HH:mm:ss.fff}" : string.Empty; + public void RefreshChangeIndicator(DateTime nowUtc) + { + var active = Current.ValueChangeRevision > 0 && Current.LastValueChangeUtc.HasValue && + nowUtc >= Current.LastValueChangeUtc.Value && + nowUtc - Current.LastValueChangeUtc.Value < TimeSpan.FromSeconds(3); + if (active == HasRecentValueChange) return; + HasRecentValueChange = active; + OnPropertyChanged(nameof(HasRecentValueChange)); + } public event PropertyChangedEventHandler? PropertyChanged; @@ -70,6 +82,9 @@ public bool Update(GooseMessageItem message) if (before.IsTest != message.IsTest) OnPropertyChanged(nameof(IsTest)); if (before.StNum != message.StNum) OnPropertyChanged(nameof(StNum)); if (before.SqNum != message.SqNum) OnPropertyChanged(nameof(SqNum)); + if (before.ValueChangeRevision != message.ValueChangeRevision) + OnPropertyChanged(nameof(LastValueChangeText)); + RefreshChangeIndicator(DateTime.UtcNow); return true; } @@ -98,8 +113,10 @@ public sealed class GoosePublisherCatalog public GoosePublisherRow? Find(GoosePublisherKey? key) => key.HasValue && _byKey.TryGetValue(key.Value, out var row) ? row : null; - public bool Reconcile(IReadOnlyList messages, GoosePublisherKey? selected) + public bool Reconcile(IReadOnlyList messages, GoosePublisherKey? selected, + IReadOnlySet? selectedSources = null) { + var nowUtc = DateTime.UtcNow; var seen = new HashSet(); var changed = false; foreach (var message in messages) @@ -124,15 +141,19 @@ public bool Reconcile(IReadOnlyList messages, GoosePublisherKe { var row = Rows[i]; if (seen.Contains(row.Key)) continue; - if (selected.HasValue && selected.Value == row.Key) + if ((selected.HasValue && selected.Value == row.Key) || + (selectedSources?.Contains(row.Key) ?? false)) { row.MarkStale(); + row.RefreshChangeIndicator(nowUtc); continue; } _byKey.Remove(row.Key); Rows.RemoveAt(i); changed = true; } + foreach (var row in Rows) + row.RefreshChangeIndicator(nowUtc); return changed; } From aad61b42589166736a86909ebfffe889f1f3e5ef Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 14:15:42 +0700 Subject: [PATCH 04/13] feat(goose): independent keyed source scope and batched visible-event follow signal --- .../ViewModels/MainWindowViewModel.cs | 107 +++++++++++------- 1 file changed, 63 insertions(+), 44 deletions(-) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index b4ab56c8..ed515ab6 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -45,6 +45,10 @@ public sealed class MainWindowViewModel : INotifyPropertyChanged, IDisposable private SvStreamItem? _selectedStream; private readonly GoosePublisherCatalog _gooseCatalog = new(); private GoosePublisherKey? _selectedGoosePublisherKey; + // Independent filter SSOT; inspector live focus and pinned historical event remain separate. + private readonly HashSet _selectedGooseSourceKeys = new(); + private bool _followGooseMessages = true; + public event Action? GooseVisibleMessagesAppended; private GooseTrafficRow? _selectedGooseTrafficRow; private bool _gooseInspectHistoricalEvent; private bool _gooseMessageExpanded = true; @@ -184,6 +188,39 @@ public DiagnosticEventItem? SelectedDiagnosticEvent public ObservableCollection GooseHistory => _gooseHistory; public ICollectionView GooseHistoryView => _gooseHistoryView; public ICollectionView GoosePublisherRowsView => _goosePublisherRowsView; + public IReadOnlySet SelectedGooseSourceKeys => _selectedGooseSourceKeys; + public int GooseSelectedSourceCount => _selectedGooseSourceKeys.Count; + public bool GooseSourceFilterActive => _selectedGooseSourceKeys.Count > 0; + public string GooseSourceScopeText => GooseSourceFilterActive + ? $"{_selectedGooseSourceKeys.Count} selected" : "All sources"; + public bool FollowGooseMessages + { + get => _followGooseMessages; + set + { + if (_followGooseMessages == value) return; + _followGooseMessages = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(GooseFollowSuspended)); + } + } + public bool GooseFollowSuspended => !_followGooseMessages; + + public void SetGooseSourceSelection(IEnumerable keys) + { + var next = keys.Take(256).ToHashSet(); + if (_selectedGooseSourceKeys.SetEquals(next)) return; + _selectedGooseSourceKeys.Clear(); + _selectedGooseSourceKeys.UnionWith(next); + OnPropertyChanged(nameof(GooseSelectedSourceCount)); + OnPropertyChanged(nameof(GooseSourceFilterActive)); + OnPropertyChanged(nameof(GooseSourceScopeText)); + OnPropertyChanged(nameof(GoosePublisherCountText)); + OnPropertyChanged(nameof(GooseEmptyGuidance)); + _gooseHistoryView.Refresh(); // operator action only; never packet-driven + UpdateGooseVisibleEventCount(); + } + public void ClearGooseSourceSelection() => SetGooseSourceSelection(Array.Empty()); // DataSet row instances survive new st/sq snapshots, preserving expanded // MMS/quality details while live retransmissions continue. public ObservableCollection GooseInspectorRows => _gooseInspectorRows; @@ -247,10 +284,7 @@ public string GoosePublisherFilterText OnPropertyChanged(nameof(GooseHasSearchText)); OnPropertyChanged(nameof(GooseFilterSummaryText)); OnPropertyChanged(nameof(GooseEmptyGuidance)); - _gooseHistoryView.Refresh(); - UpdateGooseVisibleEventCount(); _goosePublisherRowsView.Refresh(); - OnPropertyChanged(nameof(SelectedGoosePublisherRow)); } } @@ -328,10 +362,7 @@ public string SelectedGooseIdFilter OnPropertyChanged(); OnPropertyChanged(nameof(GooseFilterSummaryText)); OnPropertyChanged(nameof(GooseEmptyGuidance)); - _gooseHistoryView.Refresh(); - UpdateGooseVisibleEventCount(); - _goosePublisherRowsView.Refresh(); - OnPropertyChanged(nameof(SelectedGoosePublisherRow)); + // Legacy compatibility property is no longer an invisible timeline filter. } } @@ -340,11 +371,11 @@ public string SelectedGooseIdFilter public string GooseEmptyGuidance => _gooseMessages.Count == 0 ? "Check the selected network adapter, incoming traffic, and network group settings." : IncludeGooseRetransmission - ? "Check the message-name filter and search text." + ? "Check the selected sources or clear the filter." : "No new state changes are shown. Turn on repeated messages to see ongoing traffic."; public string GooseRetransmissionModeText => IncludeGooseRetransmission ? "Repeated messages visible" : "Showing changes only"; - public string GoosePublisherCountText => $"{_gooseMessages.Count} device{(_gooseMessages.Count == 1 ? "" : "s")}"; + public string GoosePublisherCountText => $"{_gooseMessages.Count} sources · {GooseSourceScopeText}"; public string GooseTimelineCountersText => $"{_gooseSnapshot.TotalMessages:N0} messages received · {_gooseVisibleEventCount:N0} shown"; public string GooseWorkspaceCaptureSummaryText @@ -846,6 +877,8 @@ public MainWindowViewModel() }); GooseInteractionCommand = new RelayCommand(() => DeferGooseUiFlush()); GooseReturnToLiveCommand = new RelayCommand(ReturnGooseToLive); + GooseClearSourceSelectionCommand = new RelayCommand(ClearGooseSourceSelection); + GooseFollowLiveCommand = new RelayCommand(() => FollowGooseMessages = true); GooseOpenConfigurationCommand = new RelayCommand(() => CurrentWorkspaceTabIndex = 5); GooseClearSearchCommand = new RelayCommand(() => GoosePublisherFilterText = string.Empty); @@ -1267,6 +1300,8 @@ private void ApplySelectedStreamScale(string? streamId) public ICommand SwitchToRawCommand { get; } public ICommand GooseInteractionCommand { get; } public ICommand GooseReturnToLiveCommand { get; } + public ICommand GooseClearSourceSelectionCommand { get; } + public ICommand GooseFollowLiveCommand { get; } public ICommand GooseOpenConfigurationCommand { get; } public ICommand GooseClearSearchCommand { get; } public ICommand CopyEvidenceCommand { get; } @@ -2343,7 +2378,7 @@ private async Task RefreshAsync() _gooseSnapshot = gooseSnapshot; _gooseMessages = _gooseSnapshot.Messages; - _gooseCatalog.Reconcile(_gooseMessages, _selectedGoosePublisherKey); + _gooseCatalog.Reconcile(_gooseMessages, _selectedGoosePublisherKey, _selectedGooseSourceKeys); UpdateGooseIdFilterOptions(); foreach (var msg in _gooseSnapshot.Messages) @@ -4514,7 +4549,7 @@ private async Task StopAsync() // Stop keeps the last selected publisher as STALE; Clear removes it. _gooseMessages = Array.Empty(); - _gooseCatalog.Reconcile(_gooseMessages, _selectedGoosePublisherKey); + _gooseCatalog.Reconcile(_gooseMessages, _selectedGoosePublisherKey, _selectedGooseSourceKeys); _gooseSnapshot = new GooseMonitorSnapshot(); UpdateGooseIdFilterOptions(); RaiseGooseInspectorProperties(); @@ -4536,6 +4571,8 @@ private async Task ClearAsync() _gooseSnapshot = new GooseMonitorSnapshot { StatusText = "Cleared" }; _gooseMessages = Array.Empty(); _gooseCatalog.Clear(); + ClearGooseSourceSelection(); + FollowGooseMessages = true; _selectedGoosePublisherKey = null; _gooseInspectHistoricalEvent = false; OnPropertyChanged(nameof(IsGooseHistoryPinned)); @@ -4629,42 +4666,17 @@ private static PhasorDisplayItem CreatePhasor(string name, PhasorFamily family, private bool FilterGoosePublisher(object item) => item is GoosePublisherRow row && - GoosePublisherCatalog.MatchesFilter(row.Current, SelectedGooseIdFilter, GoosePublisherFilterText); + GoosePublisherCatalog.MatchesFilter(row.Current, "All", GoosePublisherFilterText); private bool FilterGooseHistory(object item) { - if (item is not GooseTrafficRow row) - return false; - - if (!IncludeGooseRetransmission && string.Equals(row.EventType, "Retrans", StringComparison.OrdinalIgnoreCase)) + if (item is not GooseTrafficRow row) return false; + if (!IncludeGooseRetransmission && + string.Equals(row.EventType, "Retrans", StringComparison.OrdinalIgnoreCase)) return false; - - if (!string.Equals(SelectedGooseIdFilter, "All", StringComparison.OrdinalIgnoreCase)) - { - var matchesSelected = string.Equals(row.GoId, SelectedGooseIdFilter, StringComparison.OrdinalIgnoreCase) || - string.Equals(row.GoCbRef, SelectedGooseIdFilter, StringComparison.OrdinalIgnoreCase); - if (!matchesSelected) - return false; - } - - if (!string.IsNullOrWhiteSpace(GoosePublisherFilterText)) - { - var filter = GoosePublisherFilterText.Trim(); - var matchesText = Contains(row.GoId, filter) || - Contains(row.GoCbRef, filter) || - Contains(row.AppId, filter) || - Contains(row.DataSet, filter) || - Contains(row.SourceMac, filter) || - Contains(row.DestinationMac, filter); - if (!matchesText) - return false; - } - - return true; - - static bool Contains(string? text, string filter) => - !string.IsNullOrWhiteSpace(text) && - text.Contains(filter, StringComparison.OrdinalIgnoreCase); + // No search/GoID coupling: only the union of stable physical source keys. + return _selectedGooseSourceKeys.Count == 0 || + _selectedGooseSourceKeys.Contains(GoosePublisherKey.From(row.Source)); } private IReadOnlyList BuildGooseDatasetValues(GooseMessageItem? goose) @@ -4885,9 +4897,13 @@ private void FlushBufferedGooseRows(bool flushAll = false) var limit = flushAll ? int.MaxValue : GooseBufferedFlushLimit; var flushed = 0; + GooseTrafficRow? newestVisible = null; while (_pendingGooseRows.Count > 0 && flushed < limit) { - _gooseHistory.Add(_pendingGooseRows.Dequeue()); + var row = _pendingGooseRows.Dequeue(); + _gooseHistory.Add(row); + if (FilterGooseHistory(row)) + newestVisible = row; flushed++; } @@ -4902,6 +4918,9 @@ private void FlushBufferedGooseRows(bool flushAll = false) // The view observes incremental changes. Never Refresh() on each event. if (flushed > 0 || removeCount > 0) UpdateGooseVisibleEventCount(); + // Signal once per completed UI batch, never per captured packet. + if (newestVisible is not null) + GooseVisibleMessagesAppended?.Invoke(newestVisible); } private void TryFlushBufferedGooseRows() From 2c148029aed36ed9785ec9fc786677a651f5d658 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 14:17:00 +0700 Subject: [PATCH 05/13] feat(goose): move repeats to Messages and add compact source multi-select/follow controls --- src/ProcessBus.App.Wpf/MainWindow.xaml | 150 ++++++++++++++++--------- 1 file changed, 100 insertions(+), 50 deletions(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index dc1fa255..06a0d30f 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -1404,57 +1404,35 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + - Date: Wed, 21 Oct 2026 14:19:12 +0700 Subject: [PATCH 06/13] feat(goose): explicit source toggle/range controller and user-respecting batched timeline follow --- src/ProcessBus.App.Wpf/MainWindow.xaml.cs | 223 +++++++++++++++++++++- 1 file changed, 217 insertions(+), 6 deletions(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml.cs b/src/ProcessBus.App.Wpf/MainWindow.xaml.cs index bb723db5..245c8de5 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml.cs +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml.cs @@ -1,3 +1,7 @@ +using ProcessBus.Core.Presentation; +using System.Collections.Specialized; +using System.Linq; +using System.Windows.Threading; using ProcessBus.App.Wpf.ViewModels; using ProcessBus.App.Wpf.Views; using System.ComponentModel; @@ -12,6 +16,13 @@ namespace ProcessBus.App.Wpf; public partial class MainWindow : Window { private DateTime _lastGooseInteractionUtc = DateTime.MinValue; + private bool _gooseWorkspaceHooksAttached; + private bool _gooseSourceSyncing; + private bool _gooseSourceSyncQueued; + private bool _gooseSourceKeyboardSelecting; + private GoosePublisherKey? _gooseSourceRangeAnchor; + private bool _gooseFollowQueued; + private bool _gooseProgrammaticScroll; private int _pendingWorkspaceTabIndex = -1; private bool _isWorkspaceTabSwitchQueued; private bool _shutdownStarted; @@ -22,6 +33,7 @@ public MainWindow() InitializeComponent(); if (DataContext is MainWindowViewModel viewModel) { } + Loaded += GooseWorkspace_Loaded; Loaded += (_, _) => { UpdateHeaderDensity(); UpdateWorkspaceActivePill(animate: false); }; SizeChanged += (_, _) => UpdateHeaderDensity(); Closing += MainWindow_Closing; @@ -266,14 +278,213 @@ private void GooseClearSearch_Click(object sender, RoutedEventArgs e) GooseSourceSearchBox.Focus(); } + private void GooseWorkspace_Loaded(object sender, RoutedEventArgs e) + { + if (_gooseWorkspaceHooksAttached || DataContext is not MainWindowViewModel vm) return; + _gooseWorkspaceHooksAttached = true; + vm.PropertyChanged += GooseViewModel_PropertyChanged; + vm.GoosePublisherRows.CollectionChanged += GooseSources_CollectionChanged; + vm.GooseVisibleMessagesAppended += GooseVisibleMessagesAppended; + QueueGooseSourceSync(); + } + + private void GooseViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(MainWindowViewModel.GoosePublisherFilterText) + or nameof(MainWindowViewModel.GooseSelectedSourceCount)) + QueueGooseSourceSync(); + } + + private void GooseSources_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) => + QueueGooseSourceSync(); + + private void QueueGooseSourceSync() + { + if (_gooseSourceSyncQueued) return; + _gooseSourceSyncQueued = true; + Dispatcher.BeginInvoke(new Action(() => + { + _gooseSourceSyncQueued = false; + SyncGooseSourceSelection(); + }), DispatcherPriority.Background); + } + + private void SyncGooseSourceSelection() + { + if (_gooseSourceSyncing || DataContext is not MainWindowViewModel vm) return; + _gooseSourceSyncing = true; + try + { + var visible = GooseSourcesList.Items.OfType() + .Where(row => vm.SelectedGooseSourceKeys.Contains(row.Key)).ToArray(); + var selected = GooseSourcesList.SelectedItems.OfType().ToArray(); + if (visible.Length == selected.Length && visible.All(selected.Contains)) return; + GooseSourcesList.SelectedItems.Clear(); + foreach (var row in visible) + GooseSourcesList.SelectedItems.Add(row); + } + finally { _gooseSourceSyncing = false; } + } + private void GoosePublisherRow_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) { - // WPF SelectionChanged does not fire when the operator clicks an item - // that is already selected. The explicit click still means "inspect live" - // after a historical timeline event had been pinned. - if (DataContext is MainWindowViewModel viewModel && - sender is ListViewItem { DataContext: ProcessBus.Core.Presentation.GoosePublisherRow row }) - viewModel.SelectedGoosePublisherRow = row; + if (DataContext is not MainWindowViewModel vm || + sender is not ListViewItem { DataContext: GoosePublisherRow row }) + return; + + var chosen = vm.SelectedGooseSourceKeys.ToHashSet(); + var modifier = Keyboard.Modifiers; + if ((modifier & ModifierKeys.Shift) != 0) + { + var visible = GooseSourcesList.Items.OfType().ToArray(); + var anchor = Array.FindIndex(visible, item => item.Key == _gooseSourceRangeAnchor); + var target = Array.FindIndex(visible, item => item.Key == row.Key); + if (anchor < 0) anchor = target; + if (target >= 0) + for (var i = Math.Min(anchor, target); i <= Math.Max(anchor, target); i++) + chosen.Add(visible[i].Key); + } + else if ((modifier & ModifierKeys.Control) != 0) + { + if (!chosen.Add(row.Key)) chosen.Remove(row.Key); + _gooseSourceRangeAnchor = row.Key; + } + else + { + if (chosen.Count == 1 && chosen.Contains(row.Key)) chosen.Clear(); + else { chosen.Clear(); chosen.Add(row.Key); } + _gooseSourceRangeAnchor = row.Key; + } + + vm.SetGooseSourceSelection(chosen); + // Only plain click intentionally changes the live Inspector target. + if (modifier == ModifierKeys.None && chosen.Contains(row.Key)) + vm.SelectedGoosePublisherRow = row; + SyncGooseSourceSelection(); + ((ListViewItem)sender).Focus(); + e.Handled = true; // WPF must not undo our explicit toggle/range semantics. + } + + private void GooseSources_PreviewKeyDown(object sender, KeyEventArgs e) + { + if (e.Key is Key.Up or Key.Down or Key.Home or Key.End or Key.PageUp + or Key.PageDown or Key.Space) + _gooseSourceKeyboardSelecting = true; + } + + private void GooseSources_PreviewKeyUp(object sender, KeyEventArgs e) + { + _gooseSourceKeyboardSelecting = false; + } + + private void GooseSources_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_gooseSourceSyncing || !_gooseSourceKeyboardSelecting || + DataContext is not MainWindowViewModel vm) return; + // Hidden selected identities are retained when search narrows the left rail. + var visible = GooseSourcesList.Items.OfType() + .Select(row => row.Key).ToHashSet(); + var keys = vm.SelectedGooseSourceKeys.Where(key => !visible.Contains(key)).ToHashSet(); + foreach (var row in GooseSourcesList.SelectedItems.OfType()) + keys.Add(row.Key); + vm.SetGooseSourceSelection(keys); + if (Keyboard.Modifiers == ModifierKeys.None && + GooseSourcesList.SelectedItem is GoosePublisherRow focus) + vm.SelectedGoosePublisherRow = focus; + } + + private void GooseVisibleMessagesAppended(GooseTrafficRow row) + { + if (DataContext is MainWindowViewModel { FollowGooseMessages: true }) + QueueGooseFollow(); + } + + private void QueueGooseFollow() + { + if (_gooseFollowQueued || !GooseMessagesGrid.IsVisible) return; + _gooseFollowQueued = true; + Dispatcher.BeginInvoke(new Action(() => + { + _gooseFollowQueued = false; + if (DataContext is not MainWindowViewModel { FollowGooseMessages: true } vm || + !GooseMessagesGrid.IsVisible) return; + var lastVisible = vm.GooseHistoryView.OfType().LastOrDefault(); + if (lastVisible is null) return; + _gooseProgrammaticScroll = true; + GooseMessagesGrid.ScrollIntoView(lastVisible); + Dispatcher.BeginInvoke(new Action(() => _gooseProgrammaticScroll = false), + DispatcherPriority.ContextIdle); + }), DispatcherPriority.Loaded); + } + + private void GooseFollowLive_Click(object sender, RoutedEventArgs e) + { + if (DataContext is not MainWindowViewModel vm) return; + vm.FollowGooseMessages = true; + QueueGooseFollow(); + } + + private void GooseMessagesGrid_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) + { + if (e.NewValue is true && + DataContext is MainWindowViewModel { FollowGooseMessages: true }) + QueueGooseFollow(); + } + + private void SuspendGooseFollow() + { + if (!_gooseProgrammaticScroll && DataContext is MainWindowViewModel vm) + vm.FollowGooseMessages = false; + } + + private void GooseMessagesGrid_PreviewMouseWheel(object sender, MouseWheelEventArgs e) => + SuspendGooseFollow(); + + private void GooseMessagesGrid_PreviewMouseDown(object sender, MouseButtonEventArgs e) => + SuspendGooseFollow(); + + private void GooseMessagesGrid_PreviewKeyDown(object sender, KeyEventArgs e) + { + if (e.Key is Key.Up or Key.Down or Key.PageUp or Key.PageDown or + Key.Home or Key.End or Key.Space) + SuspendGooseFollow(); + } + + private void GooseMessagesGrid_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (e.AddedItems.Count > 0) SuspendGooseFollow(); + } + + private void GooseMessagesGrid_ContextMenuOpening(object sender, ContextMenuEventArgs e) => + SuspendGooseFollow(); + + private void GooseMessagesGrid_ScrollChanged(object sender, ScrollChangedEventArgs e) + { + // Real scrollbar/touch navigation away from bottom is an operator action. + // Content growth and programmatic follow do not suspend the follow state. + if (!_gooseProgrammaticScroll && e.ExtentHeightChange == 0 && + Math.Abs(e.VerticalChange) > 0.01 && + e.VerticalOffset < e.ExtentHeight - e.ViewportHeight - 2) + SuspendGooseFollow(); + } + + private void GooseMessagesGrid_LoadingRow(object sender, DataGridRowEventArgs e) + { + // Recycling must never transfer a previous event's visual accent. + e.Row.ClearValue(Control.BackgroundProperty); + if (e.Row.Item is not GooseTrafficRow row || + row.EventType == "Retrans" || + row.VisibleSinceUtc == DateTime.MinValue || + DateTime.UtcNow - row.VisibleSinceUtc > TimeSpan.FromSeconds(1.5) || + e.Row.IsSelected) return; + var brush = new SolidColorBrush(Color.FromArgb(53, 41, 125, 179)); + e.Row.Background = brush; + brush.BeginAnimation(SolidColorBrush.ColorProperty, new ColorAnimation + { + To = Color.FromArgb(0, 41, 125, 179), + Duration = new Duration(TimeSpan.FromSeconds(1.25)), + FillBehavior = FillBehavior.HoldEnd + }); } private void GooseTraffic_Interaction(object sender, MouseEventArgs e) From 58674a0dab132ae0c3819190cdd8fbd76dd6501a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 14:20:20 +0700 Subject: [PATCH 07/13] test(goose): stable physical scope, honest value change and user-visible append batching --- src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index ed515ab6..686637d4 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -4676,7 +4676,7 @@ private bool FilterGooseHistory(object item) return false; // No search/GoID coupling: only the union of stable physical source keys. return _selectedGooseSourceKeys.Count == 0 || - _selectedGooseSourceKeys.Contains(GoosePublisherKey.From(row.Source)); + GoosePublisherCatalog.MatchesSelection(row.Source, _selectedGooseSourceKeys); } private IReadOnlyList BuildGooseDatasetValues(GooseMessageItem? goose) @@ -4901,6 +4901,8 @@ private void FlushBufferedGooseRows(bool flushAll = false) while (_pendingGooseRows.Count > 0 && flushed < limit) { var row = _pendingGooseRows.Dequeue(); + if (FilterGooseHistory(row)) + row.VisibleSinceUtc = DateTime.UtcNow; _gooseHistory.Add(row); if (FilterGooseHistory(row)) newestVisible = row; @@ -5032,6 +5034,8 @@ private static Brush FrozenBrush(byte red, byte green, byte blue) public sealed class GooseTrafficRow { + // Only the first live realization after an actual UI-batch append may animate. + public DateTime VisibleSinceUtc { get; set; } public required GooseMessageItem Source { get; init; } public string EventType { get; init; } = "STATE"; From 9b1c211b273420b2c1f010f0683111b71624551f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 14:20:23 +0700 Subject: [PATCH 08/13] test(goose): stable physical scope, honest value change and user-visible append batching --- src/ProcessBus.App.Wpf/MainWindow.xaml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index 06a0d30f..4aa30e05 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -3132,6 +3132,7 @@ IsVisibleChanged="GooseMessagesGrid_IsVisibleChanged" ScrollViewer.ScrollChanged="GooseMessagesGrid_ScrollChanged" PreviewMouseWheel="GooseMessagesGrid_PreviewMouseWheel" + PreviewMouseDown="GooseMessagesGrid_PreviewMouseDown" PreviewKeyDown="GooseMessagesGrid_PreviewKeyDown" SelectionChanged="GooseMessagesGrid_SelectionChanged" ContextMenuOpening="GooseMessagesGrid_ContextMenuOpening" From eb8a20cdd15b2248b64fbb25aa5058f9b7ee0492 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 14:20:26 +0700 Subject: [PATCH 09/13] test(goose): stable physical scope, honest value change and user-visible append batching --- src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs b/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs index ad97a377..0a93a73b 100644 --- a/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs +++ b/src/ProcessBus.Core/Presentation/GoosePublisherCatalog.cs @@ -157,6 +157,11 @@ public bool Reconcile(IReadOnlyList messages, GoosePublisherKe return changed; } + /// Matches a union of physical publisher identities; empty means all. + public static bool MatchesSelection(GooseMessageItem message, + IReadOnlySet keys) => + keys.Count == 0 || keys.Contains(GoosePublisherKey.From(message)); + /// Same deterministic filter for publisher exploration; filtering /// never changes the operator's selected publisher key. public static bool MatchesFilter(GooseMessageItem message, string? gooseId, string? search) From 5e0e6a4e29df255c9fa0b313bac3040f7c00da99 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 14:20:28 +0700 Subject: [PATCH 10/13] test(goose): stable physical scope, honest value change and user-visible append batching --- .../GoosePublisherSelectionTests.cs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs b/tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs index 4b4e3366..63eaaedb 100644 --- a/tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs +++ b/tests/ProcessBus.Tests/GoosePublisherSelectionTests.cs @@ -163,6 +163,68 @@ public void TestFlagUpdatesWithoutRecreatingTheSelectedPublisherRow() Assert.Contains(nameof(GoosePublisherRow.IsTest), changed); } + [Fact] + public void SourceScopeUsesPhysicalKeysWithZeroOneAndManySelected() + { + var first = Frame(source: "02:00:4C:4E:4E:50"); + var second = Frame(source: "02:00:4C:4E:4E:51"); + var third = Frame(source: "02:00:4C:4E:4E:52"); + var keys = new HashSet(); + Assert.True(GoosePublisherCatalog.MatchesSelection(first, keys)); + Assert.True(GoosePublisherCatalog.MatchesSelection(second, keys)); + keys.Add(GoosePublisherKey.From(first)); + Assert.True(GoosePublisherCatalog.MatchesSelection(first, keys)); + Assert.False(GoosePublisherCatalog.MatchesSelection(second, keys)); + keys.Add(GoosePublisherKey.From(second)); + Assert.True(GoosePublisherCatalog.MatchesSelection(second, keys)); + Assert.False(GoosePublisherCatalog.MatchesSelection(third, keys)); + keys.Clear(); + Assert.True(GoosePublisherCatalog.MatchesSelection(third, keys)); + } + + [Fact] + public void MultipleSelectedPublishersSurviveStaleAndReappearance() + { + var catalog = new GoosePublisherCatalog(); + var a = Frame(); + var b = Frame(source: "02:00:4C:4E:4E:51"); + var keys = new HashSet { GoosePublisherKey.From(a), GoosePublisherKey.From(b) }; + catalog.Reconcile([a, b], null, keys); + var originalA = catalog.Find(GoosePublisherKey.From(a)); + var originalB = catalog.Find(GoosePublisherKey.From(b)); + catalog.Reconcile([], null, keys); + Assert.True(originalA!.IsStale); + Assert.True(originalB!.IsStale); + catalog.Reconcile([Frame(sq: 99)], null, keys); + Assert.Same(originalA, catalog.Find(GoosePublisherKey.From(a))); + Assert.Same(originalB, catalog.Find(GoosePublisherKey.From(b))); + Assert.False(originalA!.IsStale); + Assert.True(originalB!.IsStale); + } + + [Fact] + public void ChangedNoticeNeedsPositiveDecoderRevisionAndExpires() + { + var catalog = new GoosePublisherCatalog(); + var original = Frame(); + catalog.Reconcile([original], null); + var row = Assert.Single(catalog.Rows); + Assert.False(row.HasRecentValueChange); // baseline + var repeat = Frame(sq: 2); + catalog.Reconcile([repeat], null); + Assert.False(row.HasRecentValueChange); // sqNum never proves changed value + var change = Frame(st: 2); + change.ValueChangeRevision = 1; + change.LastValueChangeUtc = DateTime.UtcNow; + catalog.Reconcile([change], null); + Assert.True(row.HasRecentValueChange); + var expired = Frame(sq: 5); + expired.ValueChangeRevision = 1; + expired.LastValueChangeUtc = DateTime.UtcNow.AddSeconds(-10); + catalog.Reconcile([expired], null); + Assert.False(row.HasRecentValueChange); + } + [Fact] public void HighTrafficDoesNotCauseUnboundedRows() { From 9a0af94b834eb092313fab0e5b14646f7bf28294 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 14:24:11 +0700 Subject: [PATCH 11/13] fix(goose): avoid duplicate WPF gesture handler and keep Messages header compact --- src/ProcessBus.App.Wpf/MainWindow.xaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index 4aa30e05..4461dc6f 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -3157,8 +3157,7 @@ ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto" MouseEnter="GooseTraffic_Interaction" - MouseMove="GooseTraffic_Interaction" - PreviewMouseDown="GooseTraffic_ClickInteraction"> + MouseMove="GooseTraffic_Interaction"> From c7d059110031350b90589960f00c7c5ffbf2ef24 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 14:24:16 +0700 Subject: [PATCH 12/13] fix(goose): avoid duplicate WPF gesture handler and keep Messages header compact --- src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index 686637d4..eede611a 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -377,7 +377,7 @@ public string SelectedGooseIdFilter ? "Repeated messages visible" : "Showing changes only"; public string GoosePublisherCountText => $"{_gooseMessages.Count} sources · {GooseSourceScopeText}"; public string GooseTimelineCountersText => - $"{_gooseSnapshot.TotalMessages:N0} messages received · {_gooseVisibleEventCount:N0} shown"; + $"{_gooseSnapshot.TotalMessages:N0} received · {_gooseVisibleEventCount:N0} shown"; public string GooseWorkspaceCaptureSummaryText { get From adefbafa9b3a34f0aa582a4ec3ac4db9ccfa0b6c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 14:25:27 +0700 Subject: [PATCH 13/13] fix(goose): preserve click-time receive buffer deferral when suspending follow --- src/ProcessBus.App.Wpf/MainWindow.xaml.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml.cs b/src/ProcessBus.App.Wpf/MainWindow.xaml.cs index 245c8de5..186459d5 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml.cs +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml.cs @@ -440,8 +440,12 @@ private void SuspendGooseFollow() private void GooseMessagesGrid_PreviewMouseWheel(object sender, MouseWheelEventArgs e) => SuspendGooseFollow(); - private void GooseMessagesGrid_PreviewMouseDown(object sender, MouseButtonEventArgs e) => + private void GooseMessagesGrid_PreviewMouseDown(object sender, MouseButtonEventArgs e) + { + // Preserve the pre-existing bounded operator-interaction flush deferral. + DeferGooseTrafficFlush(); SuspendGooseFollow(); + } private void GooseMessagesGrid_PreviewKeyDown(object sender, KeyEventArgs e) {