From d327a1007ea78fb982bfc6b3fa0b0c31399c6499 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Tue, 11 Aug 2026 18:32:39 +0200 Subject: [PATCH] fix(ui): reconcile the log visualizer tick instead of clearing Rows The 4s reconcile tick called Apply(), which did Rows.Clear() + refill on every tick -- resetting the user's scroll position and selection while they're mid-read, exactly what the overlay exists to avoid. Reconcile granularly instead: find the overlap between the previous and latest snapshot (the ring buffer is append-mostly, evicting from the front as entries age out of the 30-min window), trim rows for evicted entries off the tail, and insert new entries at the head. A tick with no change now leaves Rows untouched. The WarnErrorOnly toggle still goes through the full Apply() rebuild, unchanged. --- .../Modals/LogVisualizerViewModel.cs | 66 ++++++++++++++++++- .../ViewModels/LogVisualizerViewModelTests.cs | 60 +++++++++++++++++ 2 files changed, 123 insertions(+), 3 deletions(-) diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/LogVisualizerViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/LogVisualizerViewModel.cs index b775b323..d85b017e 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/LogVisualizerViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/LogVisualizerViewModel.cs @@ -54,8 +54,8 @@ public sealed partial class LogVisualizerViewModel : ViewModelBase [RelayCommand] public async Task RefreshAsync() { - _all = await _worker.GetRecentLogsAsync(); - Apply(); + var latest = await _worker.GetRecentLogsAsync(); + Reconcile(latest); } partial void OnWarnErrorOnlyChanged(bool value) => Apply(); @@ -68,10 +68,70 @@ public sealed partial class LogVisualizerViewModel : ViewModelBase : _all; foreach (var e in items.Reverse()) Rows.Add(new LogVisualizerRow(e.TimestampUtc.ToLocalTime().ToString("HH:mm:ss"), e.Message, e.Level)); + UpdateStatusText(); + } + + // `_all` (oldest-first) mirrors the worker's LogRingBuffer: append-mostly, with the oldest + // entries falling off the front once they age past the 30-min window. A full Rows.Clear() + // on every 4s tick would reset the user's scroll position/selection while they're mid-read, + // so patch just the delta instead: entries still present keep their row, aged-out entries + // are trimmed off the (oldest) tail of Rows, new entries are inserted at the (newest) head. + private void Reconcile(IReadOnlyList latest) + { + var previous = _all; + _all = latest; + + if (previous.Count == 0) + { + Apply(); + return; + } + + var overlap = FindOverlap(previous, latest); + var evictedCount = previous.Count - overlap; + var added = latest.Skip(overlap).ToList(); + if (evictedCount == 0 && added.Count == 0) + return; // nothing aged out, nothing new — leave Rows (and scroll/selection) untouched + + if (evictedCount > 0) + { + var removeCount = WarnErrorOnly + ? previous.Take(evictedCount).Count(e => e.Level is WorkerLogLevel.Warn or WorkerLogLevel.Error) + : evictedCount; + for (var i = 0; i < removeCount; i++) + Rows.RemoveAt(Rows.Count - 1); + } + + foreach (var e in added) + { + if (WarnErrorOnly && e.Level is not (WorkerLogLevel.Warn or WorkerLogLevel.Error)) + continue; + Rows.Insert(0, new LogVisualizerRow(e.TimestampUtc.ToLocalTime().ToString("HH:mm:ss"), e.Message, e.Level)); + } + + UpdateStatusText(); + } + + // The longest run of `previous`'s tail that reappears as `latest`'s head — i.e. how much of + // the old snapshot survived unevicted. 0 when nothing overlaps (e.g. right after opening). + private static int FindOverlap(IReadOnlyList previous, IReadOnlyList latest) + { + for (var k = Math.Min(previous.Count, latest.Count); k > 0; k--) + { + var matches = true; + for (var i = 0; i < k; i++) + { + if (!previous[previous.Count - k + i].Equals(latest[i])) { matches = false; break; } + } + if (matches) return k; + } + return 0; + } + + private void UpdateStatusText() => StatusText = Rows.Count == 0 ? Loc.T("modals.logVisualizer.empty") : Loc.T("modals.logVisualizer.count", Rows.Count); - } [RelayCommand] private void Close() diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/LogVisualizerViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/LogVisualizerViewModelTests.cs index 5e03da0d..1ce6e0d1 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/LogVisualizerViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/LogVisualizerViewModelTests.cs @@ -13,6 +13,14 @@ public class LogVisualizerViewModelTests public override Task> GetRecentLogsAsync() => Task.FromResult(_logs); } + // Simulates the worker's append-mostly LogRingBuffer across successive ticks: each + // RefreshAsync call returns whatever `Logs` currently holds. + private sealed class MutableFakeClient : StubWorkerClient + { + public IReadOnlyList Logs { get; set; } = Array.Empty(); + public override Task> GetRecentLogsAsync() => Task.FromResult(Logs); + } + private static WorkerLogEntry E(WorkerLogLevel lvl, string msg) => new(msg, lvl, new DateTime(2026, 6, 23, 8, 0, 0, DateTimeKind.Utc)); @@ -176,4 +184,56 @@ public class LogVisualizerViewModelTests Assert.False(string.IsNullOrEmpty(vm.StatusText)); } + + [Fact] + public async Task Tick_with_no_new_data_does_not_touch_rows() + { + var t0 = new DateTime(2026, 6, 23, 8, 0, 0, DateTimeKind.Utc); + var client = new MutableFakeClient { Logs = new[] { E(WorkerLogLevel.Info, "a", t0) } }; + var vm = new LogVisualizerViewModel(client); + await vm.RefreshAsync(); + var row = vm.Rows.Single(); + + var changeCount = 0; + vm.Rows.CollectionChanged += (_, _) => changeCount++; + await vm.RefreshAsync(); // same snapshot on the next tick + + Assert.Equal(0, changeCount); + Assert.Same(row, vm.Rows.Single()); + } + + [Fact] + public async Task Tick_appends_a_new_entry_without_clearing_existing_rows() + { + var t0 = new DateTime(2026, 6, 23, 8, 0, 0, DateTimeKind.Utc); + var client = new MutableFakeClient { Logs = new[] { E(WorkerLogLevel.Info, "a", t0) } }; + var vm = new LogVisualizerViewModel(client); + await vm.RefreshAsync(); + var existingRow = vm.Rows.Single(); + + client.Logs = new[] { E(WorkerLogLevel.Info, "a", t0), E(WorkerLogLevel.Info, "b", t0.AddMinutes(1)) }; + await vm.RefreshAsync(); + + Assert.Equal(new[] { "b", "a" }, vm.Rows.Select(r => r.Message)); + Assert.Same(existingRow, vm.Rows[1]); // the pre-existing row instance survived the tick + } + + [Fact] + public async Task Tick_drops_the_row_for_an_entry_that_aged_out_of_the_window() + { + var t0 = new DateTime(2026, 6, 23, 8, 0, 0, DateTimeKind.Utc); + var client = new MutableFakeClient + { + Logs = new[] { E(WorkerLogLevel.Info, "a", t0), E(WorkerLogLevel.Info, "b", t0.AddMinutes(1)) }, + }; + var vm = new LogVisualizerViewModel(client); + await vm.RefreshAsync(); + Assert.Equal(new[] { "b", "a" }, vm.Rows.Select(r => r.Message)); + + // "a" fell out of the ring buffer's 30-min window; "c" is a new entry. + client.Logs = new[] { E(WorkerLogLevel.Info, "b", t0.AddMinutes(1)), E(WorkerLogLevel.Info, "c", t0.AddMinutes(2)) }; + await vm.RefreshAsync(); + + Assert.Equal(new[] { "c", "b" }, vm.Rows.Select(r => r.Message)); + } }