diff --git a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
index dc83e5e..7759ff4 100644
--- a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
+++ b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
@@ -11,6 +11,10 @@
+
+
+
+
net8.0
enable
diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
index 14d5f49..f7beed4 100644
--- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
@@ -1,5 +1,7 @@
+using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
+using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
@@ -7,33 +9,44 @@ namespace ClaudeDo.Ui.ViewModels;
public sealed partial class IslandsShellViewModel : ViewModelBase
{
- public ListsIslandViewModel Lists { get; }
- public TasksIslandViewModel Tasks { get; }
- public DetailsIslandViewModel Details { get; }
- public WorkerClient Worker { get; }
+ public ListsIslandViewModel? Lists { get; }
+ public TasksIslandViewModel? Tasks { get; }
+ public DetailsIslandViewModel? Details { get; }
+ public WorkerClient? Worker { get; }
public string ConnectionText =>
- Worker.IsConnected ? "Online"
- : Worker.IsReconnecting ? "Connecting…"
+ Worker?.IsConnected == true ? "Online"
+ : Worker?.IsReconnecting == true ? "Connecting…"
: "Offline";
- public bool IsOffline => !Worker.IsConnected && !Worker.IsReconnecting;
+ public bool IsOffline => Worker?.IsConnected != true && Worker?.IsReconnecting != true;
[ObservableProperty]
private double _windowWidth = 1280;
+ [ObservableProperty]
+ private string? _workerLogText;
+
+ [ObservableProperty]
+ private WorkerLogLevel _workerLogLevel;
+
+ [ObservableProperty]
+ private bool _isWorkerLogVisible;
+
public bool ShowDetails => WindowWidth >= 1100;
public bool ShowLists => WindowWidth >= 780;
- [RelayCommand]
- private void FocusSearch() => Lists.RequestFocusSearch();
+ private readonly System.Timers.Timer _clearTimer = new(30_000) { AutoReset = false };
[RelayCommand]
- private void FocusAddTask() => Tasks.RequestFocusAddTask();
+ private void FocusSearch() => Lists?.RequestFocusSearch();
+
+ [RelayCommand]
+ private void FocusAddTask() => Tasks?.RequestFocusAddTask();
public async Task ToggleSelectedDoneAsync()
{
- if (Tasks.SelectedTask is { } row)
+ if (Tasks?.SelectedTask is { } row)
await Tasks.ToggleDoneCommand.ExecuteAsync(row);
}
@@ -43,6 +56,25 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
OnPropertyChanged(nameof(ShowLists));
}
+ public void OnWorkerLogReceived(WorkerLogEntry entry)
+ {
+ var hhmm = entry.TimestampUtc.ToLocalTime().ToString("HH:mm");
+ WorkerLogText = $"{hhmm} · {entry.Message}";
+ WorkerLogLevel = entry.Level;
+ IsWorkerLogVisible = true;
+ _clearTimer.Stop();
+ _clearTimer.Start();
+ }
+
+ public void ClearWorkerLog()
+ {
+ IsWorkerLogVisible = false;
+ WorkerLogText = null;
+ }
+
+ // For tests only — does NOT wire up events.
+ internal IslandsShellViewModel() { }
+
public IslandsShellViewModel(
ListsIslandViewModel lists,
TasksIslandViewModel tasks,
@@ -68,6 +100,14 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
OnPropertyChanged(nameof(IsOffline));
}
};
+ Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
+ _clearTimer.Elapsed += (_, _) =>
+ {
+ if (Dispatcher.UIThread.CheckAccess())
+ ClearWorkerLog();
+ else
+ Dispatcher.UIThread.Post(ClearWorkerLog);
+ };
_ = Lists.LoadAsync();
}
}
diff --git a/tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs b/tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs
new file mode 100644
index 0000000..a19527d
--- /dev/null
+++ b/tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs
@@ -0,0 +1,58 @@
+using System;
+using ClaudeDo.Data.Models;
+using ClaudeDo.Ui.Services;
+using ClaudeDo.Ui.ViewModels;
+using Xunit;
+
+namespace ClaudeDo.Ui.Tests;
+
+public class IslandsShellViewModelWorkerLogTests
+{
+ [Fact]
+ public void Receiving_event_sets_text_level_and_visible()
+ {
+ var vm = new IslandsShellViewModel();
+ var at = new DateTime(2026, 4, 23, 14, 32, 0, DateTimeKind.Utc);
+
+ vm.OnWorkerLogReceived(new WorkerLogEntry("Created worktree for \"X\"", WorkerLogLevel.Info, at));
+
+ Assert.True(vm.IsWorkerLogVisible);
+ Assert.Equal(WorkerLogLevel.Info, vm.WorkerLogLevel);
+ Assert.Contains("Created worktree for \"X\"", vm.WorkerLogText);
+ }
+
+ [Fact]
+ public void Second_event_replaces_first()
+ {
+ var vm = new IslandsShellViewModel();
+ vm.OnWorkerLogReceived(new WorkerLogEntry("first", WorkerLogLevel.Info, DateTime.UtcNow));
+ vm.OnWorkerLogReceived(new WorkerLogEntry("second", WorkerLogLevel.Success, DateTime.UtcNow));
+
+ Assert.Contains("second", vm.WorkerLogText);
+ Assert.Equal(WorkerLogLevel.Success, vm.WorkerLogLevel);
+ }
+
+ [Fact]
+ public void ClearWorkerLog_hides_line()
+ {
+ var vm = new IslandsShellViewModel();
+ vm.OnWorkerLogReceived(new WorkerLogEntry("msg", WorkerLogLevel.Info, DateTime.UtcNow));
+
+ vm.ClearWorkerLog();
+
+ Assert.False(vm.IsWorkerLogVisible);
+ Assert.Null(vm.WorkerLogText);
+ }
+
+ [Fact]
+ public void Text_is_formatted_as_HHmm_dot_message_local_time()
+ {
+ var vm = new IslandsShellViewModel();
+ var utc = new DateTime(2026, 4, 23, 12, 0, 0, DateTimeKind.Utc);
+ var expectedLocalHhmm = utc.ToLocalTime().ToString("HH:mm");
+
+ vm.OnWorkerLogReceived(new WorkerLogEntry("hello", WorkerLogLevel.Info, utc));
+
+ Assert.StartsWith(expectedLocalHhmm + " · ", vm.WorkerLogText);
+ }
+}