From c956ab102ef38005f9bd9f1ac514f546ceab6aec Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 1 Oct 2026 17:35:03 +0200 Subject: [PATCH] fix(ui): Log Visualizer shows every footer-log message Co-Authored-By: Claude Sonnet 5.5 --- src/ClaudeDo.Ui/CLAUDE.md | 2 +- .../ViewModels/IslandsShellViewModel.cs | 18 ++++++- .../Modals/LogVisualizerViewModel.cs | 32 +++++++++--- .../IslandsShellViewModelWorkerLogTests.cs | 49 +++++++++++++++++++ .../ViewModels/LogVisualizerViewModelTests.cs | 32 ++++++------ 5 files changed, 110 insertions(+), 23 deletions(-) diff --git a/src/ClaudeDo.Ui/CLAUDE.md b/src/ClaudeDo.Ui/CLAUDE.md index 9db5d820..eb03af1a 100644 --- a/src/ClaudeDo.Ui/CLAUDE.md +++ b/src/ClaudeDo.Ui/CLAUDE.md @@ -63,7 +63,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles) Self-explanatory: `RepoImportModalViewModel` (bulk-create lists from git repos; already-wired repos disabled), `MergeModalViewModel`, `WorktreesOverviewModalViewModel`, -`UnfinishedPlanningModalViewModel`, `LogVisualizerViewModel` (last 30 min, all levels + a +`UnfinishedPlanningModalViewModel`, `LogVisualizerViewModel` (last 30 min, all levels, merged with the shell's footer-strip history — worker Warn/Error copies deduped by record equality — + a warn/error filter), `WorkerConnectionModalViewModel`, `AboutModalViewModel`. `CheatSheetsViewModel` backs the modeless `CheatSheetsWindow` (Help → Cheat Sheets): content is diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index 8536360a..d23b64fd 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -181,8 +181,23 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable OnPropertyChanged(nameof(TasksColumnSpan)); } + private const int FooterHistoryCap = 500; + + /// Every line the footer strip has shown this session, oldest-first, capped; UI thread only. + private readonly List _footerHistory = new(); + + internal IReadOnlyList FooterHistorySnapshot() => _footerHistory.ToArray(); + + private void RecordFooter(WorkerLogEntry entry) + { + _footerHistory.Add(entry); + if (_footerHistory.Count > FooterHistoryCap) + _footerHistory.RemoveAt(0); + } + public void OnWorkerLogReceived(WorkerLogEntry entry) { + RecordFooter(entry); var hhmm = entry.TimestampUtc.ToLocalTime().ToString("HH:mm"); WorkerLogText = $"{hhmm} · {entry.Message}"; WorkerLogLevel = entry.Level; @@ -214,6 +229,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable if (level == WorkerLogLevel.Error) Serilog.Log.Warning("Footer error: {message}", message); + RecordFooter(new WorkerLogEntry(message, level, DateTime.UtcNow)); WorkerLogText = $"{DateTime.Now:HH:mm} · {message}"; WorkerLogLevel = level; IsWorkerLogVisible = true; @@ -541,7 +557,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable private async Task OpenLogVisualizer() { if (Dialogs is null || Worker is null) return; - var vm = new LogVisualizerViewModel(Worker); + var vm = new LogVisualizerViewModel(Worker, FooterHistorySnapshot); await vm.RefreshAsync(); await Dialogs.ShowLogVisualizerAsync(vm); } diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/LogVisualizerViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/LogVisualizerViewModel.cs index d85b017e..166d70d6 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/LogVisualizerViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/LogVisualizerViewModel.cs @@ -15,15 +15,16 @@ using CommunityToolkit.Mvvm.Input; namespace ClaudeDo.Ui.ViewModels.Modals; /// -/// Log Visualizer overlay — shows the worker's last 30 min of log records (all levels), -/// fetched once on open via with a manual -/// Refresh and a "warnings & errors only" filter. +/// Log Visualizer overlay — shows the worker's last 30 min of log records (all levels, via +/// ) merged with the shell's footer-strip history, +/// with a manual Refresh, a 4 s tick and a "warnings & errors only" filter. /// public sealed partial class LogVisualizerViewModel : ViewModelBase { private const int CopyLastCount = 40; private readonly IWorkerClient _worker; + private readonly Func> _footerHistory; private readonly Func> _copyToClipboard; private IReadOnlyList _all = Array.Empty(); @@ -34,11 +35,15 @@ public sealed partial class LogVisualizerViewModel : ViewModelBase public Action? CloseAction { get; set; } - public LogVisualizerViewModel(IWorkerClient worker) : this(worker, CopyToClipboardAsync) { } + /// Creates the overlay; returns the shell's footer lines oldest-first. + public LogVisualizerViewModel(IWorkerClient worker, Func> footerHistory) + : this(worker, footerHistory, CopyToClipboardAsync) { } - internal LogVisualizerViewModel(IWorkerClient worker, Func> copyToClipboard) + internal LogVisualizerViewModel(IWorkerClient worker, Func> footerHistory, + Func> copyToClipboard) { _worker = worker; + _footerHistory = footerHistory; _copyToClipboard = copyToClipboard; Rows.CollectionChanged += (_, _) => CopyLastCommand.NotifyCanExecuteChanged(); // Phase 3 reconcile tick: this overlay is long-lived (stays open while the user reads @@ -55,7 +60,18 @@ public sealed partial class LogVisualizerViewModel : ViewModelBase public async Task RefreshAsync() { var latest = await _worker.GetRecentLogsAsync(); - Reconcile(latest); + Reconcile(Merge(latest)); + } + + // Worker Warn/Error lines reach the footer through BroadcastLogSink with the exact message, + // level and timestamp of their ring-buffer copy, so record equality drops the duplicate. + // OrderBy is stable: buffer entries precede footer entries on a timestamp tie, which keeps + // the merged order identical across ticks (Reconcile's overlap match depends on that). + private List Merge(IReadOnlyList buffer) + { + var known = buffer.ToHashSet(); + return buffer.Concat(_footerHistory().Where(e => !known.Contains(e))) + .OrderBy(e => e.TimestampUtc).ToList(); } partial void OnWarnErrorOnlyChanged(bool value) => Apply(); @@ -71,11 +87,13 @@ public sealed partial class LogVisualizerViewModel : ViewModelBase UpdateStatusText(); } - // `_all` (oldest-first) mirrors the worker's LogRingBuffer: append-mostly, with the oldest + // `_all` (oldest-first) is the merged buffer + footer list: 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. + // An entry merged into the middle shrinks the overlap, so more rows are dropped and + // re-added — still correct, it just doesn't preserve scroll for that rare tick. private void Reconcile(IReadOnlyList latest) { var previous = _all; diff --git a/tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs b/tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs index a19527da..0b8eaaf4 100644 --- a/tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs +++ b/tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs @@ -1,7 +1,11 @@ using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; using ClaudeDo.Data.Models; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels; +using ClaudeDo.Ui.ViewModels.Modals; using Xunit; namespace ClaudeDo.Ui.Tests; @@ -55,4 +59,49 @@ public class IslandsShellViewModelWorkerLogTests Assert.StartsWith(expectedLocalHhmm + " · ", vm.WorkerLogText); } + + [Fact] + public async Task Visualizer_shows_flashed_and_received_footer_lines_with_their_levels() + { + var shell = new IslandsShellViewModel(); + shell.FlashFooterInfo("claude 2.0 updating"); + shell.OnWorkerLogReceived(new WorkerLogEntry("task merged", WorkerLogLevel.Success, DateTime.UtcNow.AddSeconds(1))); + var vm = new LogVisualizerViewModel(new BufferClient(), shell.FooterHistorySnapshot); + + await vm.RefreshAsync(); + + Assert.Equal(new[] { ("task merged", WorkerLogLevel.Success), ("claude 2.0 updating", WorkerLogLevel.Info) }, + vm.Rows.Select(r => (r.Message, r.Level))); + } + + [Fact] + public async Task Footer_line_arriving_while_visualizer_is_open_shows_on_next_refresh() + { + var shell = new IslandsShellViewModel(); + var vm = new LogVisualizerViewModel(new BufferClient(), shell.FooterHistorySnapshot); + await vm.RefreshAsync(); + + shell.FlashFooterError("boom"); + await vm.RefreshAsync(); + + Assert.Equal(("boom", WorkerLogLevel.Error), (vm.Rows.Single().Message, vm.Rows.Single().Level)); + } + + [Fact] + public async Task Worker_warn_in_both_footer_history_and_buffer_appears_once() + { + var shell = new IslandsShellViewModel(); + var warn = new WorkerLogEntry("disk low", WorkerLogLevel.Warn, DateTime.UtcNow); + shell.OnWorkerLogReceived(warn); + var vm = new LogVisualizerViewModel(new BufferClient(warn), shell.FooterHistorySnapshot); + + await vm.RefreshAsync(); + + Assert.Single(vm.Rows); + } + + private sealed class BufferClient(params WorkerLogEntry[] logs) : StubWorkerClient + { + public override Task> GetRecentLogsAsync() => Task.FromResult>(logs); + } } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/LogVisualizerViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/LogVisualizerViewModelTests.cs index 1ce6e0d1..78817149 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/LogVisualizerViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/LogVisualizerViewModelTests.cs @@ -21,6 +21,10 @@ public class LogVisualizerViewModelTests public override Task> GetRecentLogsAsync() => Task.FromResult(Logs); } + private static LogVisualizerViewModel Vm(IWorkerClient client, + Func>? copy = null, Func>? footer = null) + => new(client, footer ?? (() => Array.Empty()), copy ?? (_ => Task.FromResult(true))); + private static WorkerLogEntry E(WorkerLogLevel lvl, string msg) => new(msg, lvl, new DateTime(2026, 6, 23, 8, 0, 0, DateTimeKind.Utc)); @@ -30,7 +34,7 @@ public class LogVisualizerViewModelTests [Fact] public async Task Refresh_populates_rows_from_worker_newest_first() { - var vm = new LogVisualizerViewModel(new FakeClient(new[] { E(WorkerLogLevel.Info, "a"), E(WorkerLogLevel.Error, "b") })); + var vm = Vm(new FakeClient(new[] { E(WorkerLogLevel.Info, "a"), E(WorkerLogLevel.Error, "b") })); await vm.RefreshAsync(); @@ -40,7 +44,7 @@ public class LogVisualizerViewModelTests [Fact] public async Task WarnErrorOnly_filters_out_info() { - var vm = new LogVisualizerViewModel(new FakeClient(new[] + var vm = Vm(new FakeClient(new[] { E(WorkerLogLevel.Info, "a"), E(WorkerLogLevel.Warn, "w"), E(WorkerLogLevel.Error, "e") })); await vm.RefreshAsync(); @@ -52,7 +56,7 @@ public class LogVisualizerViewModelTests [Fact] public async Task Empty_logs_yield_no_rows_and_a_status() { - var vm = new LogVisualizerViewModel(new FakeClient(Array.Empty())); + var vm = Vm(new FakeClient(Array.Empty())); await vm.RefreshAsync(); @@ -64,7 +68,7 @@ public class LogVisualizerViewModelTests public async Task Refresh_orders_newest_entry_first() { var t0 = new DateTime(2026, 6, 23, 8, 0, 0, DateTimeKind.Utc); - var vm = new LogVisualizerViewModel(new FakeClient(new[] + var vm = Vm(new FakeClient(new[] { E(WorkerLogLevel.Info, "oldest", t0), E(WorkerLogLevel.Info, "middle", t0.AddMinutes(1)), @@ -80,7 +84,7 @@ public class LogVisualizerViewModelTests public async Task Refresh_orders_newest_entry_first_with_warn_error_only() { var t0 = new DateTime(2026, 6, 23, 8, 0, 0, DateTimeKind.Utc); - var vm = new LogVisualizerViewModel(new FakeClient(new[] + var vm = Vm(new FakeClient(new[] { E(WorkerLogLevel.Warn, "oldest", t0), E(WorkerLogLevel.Info, "skipped", t0.AddMinutes(1)), @@ -98,7 +102,7 @@ public class LogVisualizerViewModelTests public async Task CopyLast_orders_chronologically_oldest_first() { var t0 = new DateTime(2026, 6, 23, 8, 0, 0, DateTimeKind.Utc); - var vm = new LogVisualizerViewModel(new FakeClient(new[] + var vm = Vm(new FakeClient(new[] { E(WorkerLogLevel.Info, "oldest", t0), E(WorkerLogLevel.Warn, "middle", t0.AddMinutes(1)), @@ -122,7 +126,7 @@ public class LogVisualizerViewModelTests var entries = Enumerable.Range(0, 50) .Select(i => E(WorkerLogLevel.Info, $"line{i}", t0.AddMinutes(i))) .ToArray(); - var vm = new LogVisualizerViewModel(new FakeClient(entries)); + var vm = Vm(new FakeClient(entries)); await vm.RefreshAsync(); var lines = vm.BuildCopyText().Split('\n'); @@ -136,7 +140,7 @@ public class LogVisualizerViewModelTests public async Task CopyLast_respects_active_warn_error_filter() { var t0 = new DateTime(2026, 6, 23, 8, 0, 0, DateTimeKind.Utc); - var vm = new LogVisualizerViewModel(new FakeClient(new[] + var vm = Vm(new FakeClient(new[] { E(WorkerLogLevel.Info, "skipped", t0), E(WorkerLogLevel.Warn, "kept", t0.AddMinutes(1)), @@ -153,7 +157,7 @@ public class LogVisualizerViewModelTests [Fact] public async Task CopyLastCommand_disabled_when_no_rows() { - var vm = new LogVisualizerViewModel(new FakeClient(Array.Empty())); + var vm = Vm(new FakeClient(Array.Empty())); await vm.RefreshAsync(); Assert.False(vm.CopyLastCommand.CanExecute(null)); @@ -163,7 +167,7 @@ public class LogVisualizerViewModelTests public async Task CopyLastCommand_copies_and_reports_count_on_success() { string? copied = null; - var vm = new LogVisualizerViewModel(new FakeClient(new[] { E(WorkerLogLevel.Info, "a") }), + var vm = Vm(new FakeClient(new[] { E(WorkerLogLevel.Info, "a") }), text => { copied = text; return Task.FromResult(true); }); await vm.RefreshAsync(); @@ -176,7 +180,7 @@ public class LogVisualizerViewModelTests [Fact] public async Task CopyLastCommand_reports_failure_when_clipboard_unavailable() { - var vm = new LogVisualizerViewModel(new FakeClient(new[] { E(WorkerLogLevel.Info, "a") }), + var vm = Vm(new FakeClient(new[] { E(WorkerLogLevel.Info, "a") }), _ => Task.FromResult(false)); await vm.RefreshAsync(); @@ -190,7 +194,7 @@ public class LogVisualizerViewModelTests { 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); + var vm = Vm(client); await vm.RefreshAsync(); var row = vm.Rows.Single(); @@ -207,7 +211,7 @@ public class LogVisualizerViewModelTests { 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); + var vm = Vm(client); await vm.RefreshAsync(); var existingRow = vm.Rows.Single(); @@ -226,7 +230,7 @@ public class LogVisualizerViewModelTests { Logs = new[] { E(WorkerLogLevel.Info, "a", t0), E(WorkerLogLevel.Info, "b", t0.AddMinutes(1)) }, }; - var vm = new LogVisualizerViewModel(client); + var vm = Vm(client); await vm.RefreshAsync(); Assert.Equal(new[] { "b", "a" }, vm.Rows.Select(r => r.Message));