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));