feat(ui): add worker log state and 30s timer to shell VM

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-04-23 14:56:58 +02:00
parent f906e7086c
commit ec4ec44603
3 changed files with 113 additions and 11 deletions

View File

@@ -11,6 +11,10 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ClaudeDo.Ui.Tests" />
</ItemGroup>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>

View File

@@ -1,5 +1,7 @@
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Islands;
@@ -7,33 +9,44 @@ namespace ClaudeDo.Ui.ViewModels;
public sealed partial class IslandsShellViewModel : ViewModelBase public sealed partial class IslandsShellViewModel : ViewModelBase
{ {
public ListsIslandViewModel Lists { get; } public ListsIslandViewModel? Lists { get; }
public TasksIslandViewModel Tasks { get; } public TasksIslandViewModel? Tasks { get; }
public DetailsIslandViewModel Details { get; } public DetailsIslandViewModel? Details { get; }
public WorkerClient Worker { get; } public WorkerClient? Worker { get; }
public string ConnectionText => public string ConnectionText =>
Worker.IsConnected ? "Online" Worker?.IsConnected == true ? "Online"
: Worker.IsReconnecting ? "Connecting…" : Worker?.IsReconnecting == true ? "Connecting…"
: "Offline"; : "Offline";
public bool IsOffline => !Worker.IsConnected && !Worker.IsReconnecting; public bool IsOffline => Worker?.IsConnected != true && Worker?.IsReconnecting != true;
[ObservableProperty] [ObservableProperty]
private double _windowWidth = 1280; private double _windowWidth = 1280;
[ObservableProperty]
private string? _workerLogText;
[ObservableProperty]
private WorkerLogLevel _workerLogLevel;
[ObservableProperty]
private bool _isWorkerLogVisible;
public bool ShowDetails => WindowWidth >= 1100; public bool ShowDetails => WindowWidth >= 1100;
public bool ShowLists => WindowWidth >= 780; public bool ShowLists => WindowWidth >= 780;
[RelayCommand] private readonly System.Timers.Timer _clearTimer = new(30_000) { AutoReset = false };
private void FocusSearch() => Lists.RequestFocusSearch();
[RelayCommand] [RelayCommand]
private void FocusAddTask() => Tasks.RequestFocusAddTask(); private void FocusSearch() => Lists?.RequestFocusSearch();
[RelayCommand]
private void FocusAddTask() => Tasks?.RequestFocusAddTask();
public async Task ToggleSelectedDoneAsync() public async Task ToggleSelectedDoneAsync()
{ {
if (Tasks.SelectedTask is { } row) if (Tasks?.SelectedTask is { } row)
await Tasks.ToggleDoneCommand.ExecuteAsync(row); await Tasks.ToggleDoneCommand.ExecuteAsync(row);
} }
@@ -43,6 +56,25 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
OnPropertyChanged(nameof(ShowLists)); 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( public IslandsShellViewModel(
ListsIslandViewModel lists, ListsIslandViewModel lists,
TasksIslandViewModel tasks, TasksIslandViewModel tasks,
@@ -68,6 +100,14 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
OnPropertyChanged(nameof(IsOffline)); OnPropertyChanged(nameof(IsOffline));
} }
}; };
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
_clearTimer.Elapsed += (_, _) =>
{
if (Dispatcher.UIThread.CheckAccess())
ClearWorkerLog();
else
Dispatcher.UIThread.Post(ClearWorkerLog);
};
_ = Lists.LoadAsync(); _ = Lists.LoadAsync();
} }
} }

View File

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