feat(ui): wire avalonia desktop ui to data and worker

App: build a ServiceProvider in Program.cs (AppSettings, SqliteConnectionFactory,
all repositories, GitService, WorkerClient, all view-models), apply schema, then
hand control to Avalonia. App.OnFrameworkInitializationCompleted resolves
MainWindowViewModel from the container.

Ui:
- AppSettings POCO loaded from ~/.todo-app/ui.config.json (db path, hub url).
- WorkerClient wraps HubConnection with auto-reconnect, exposes IsConnected and
  ActiveTasks plus C# events for TaskStarted/Finished/Message/Updated and
  WorktreeUpdated; all inbound events are marshalled to the UI thread.
- ViewModels: MainWindow (lists CRUD via ListEditor dialog), TaskList (load by
  list, add/edit/delete, auto WakeQueue on agent+queued create), TaskItem
  (RunNow gated on connection + status), TaskDetail (description, result, live
  ndjson rolling buffer of 500 lines, worktree branch/diff with merge/keep/
  discard via GitService), StatusBar, ListEditor, TaskEditor.
- Views: 3-pane MainWindow (lists | tasks | detail) with GridSplitters, status
  bar, dialog windows for the editors. Status badges via StatusColorConverter.
- Markdown rendering, folder picker, delete-confirmation, settings dialog and
  scroll-to-bottom on the live log are intentionally TODO -- functional
  scaffold only.

Tests: also debounce the FIFO queue test (poll instead of Task.Delay(200)) so
the assertion isn't racy when the suite runs alongside the slower git tests.
38 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mika Kuns
2026-04-13 14:01:03 +02:00
parent 01235d986f
commit 48e4aabeb1
28 changed files with 1527 additions and 26 deletions

View File

@@ -0,0 +1,154 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.AspNetCore.SignalR.Client;
namespace ClaudeDo.Ui.Services;
public record ActiveTask(string Slot, string TaskId, DateTime StartedAt);
public partial class WorkerClient : ObservableObject, IAsyncDisposable
{
private readonly HubConnection _hub;
[ObservableProperty]
private bool _isConnected;
public ObservableCollection<ActiveTask> ActiveTasks { get; } = new();
public event Action<string, string, DateTime>? TaskStartedEvent;
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
public event Action<string, string>? TaskMessageEvent;
public event Action<string>? TaskUpdatedEvent;
public event Action<string>? WorktreeUpdatedEvent;
public WorkerClient(string signalRUrl)
{
_hub = new HubConnectionBuilder()
.WithUrl(signalRUrl)
.WithAutomaticReconnect()
.Build();
_hub.Reconnected += async _ =>
{
Dispatcher.UIThread.Post(() => IsConnected = true);
await SeedActiveTasksAsync();
};
_hub.Reconnecting += _ =>
{
Dispatcher.UIThread.Post(() => IsConnected = false);
return Task.CompletedTask;
};
_hub.Closed += _ =>
{
Dispatcher.UIThread.Post(() =>
{
IsConnected = false;
ActiveTasks.Clear();
});
return Task.CompletedTask;
};
_hub.On<string, string, DateTime>("TaskStarted", (slot, taskId, startedAt) =>
{
Dispatcher.UIThread.Post(() =>
{
ActiveTasks.Add(new ActiveTask(slot, taskId, startedAt));
TaskStartedEvent?.Invoke(slot, taskId, startedAt);
});
});
_hub.On<string, string, string, DateTime>("TaskFinished", (slot, taskId, status, finishedAt) =>
{
Dispatcher.UIThread.Post(() =>
{
var existing = ActiveTasks.FirstOrDefault(t => t.TaskId == taskId);
if (existing is not null)
ActiveTasks.Remove(existing);
TaskFinishedEvent?.Invoke(slot, taskId, status, finishedAt);
});
});
_hub.On<string, string>("TaskMessage", (taskId, line) =>
{
Dispatcher.UIThread.Post(() => TaskMessageEvent?.Invoke(taskId, line));
});
_hub.On<string>("TaskUpdated", taskId =>
{
Dispatcher.UIThread.Post(() => TaskUpdatedEvent?.Invoke(taskId));
});
_hub.On<string>("WorktreeUpdated", taskId =>
{
Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId));
});
}
public async Task StartAsync()
{
try
{
await _hub.StartAsync();
Dispatcher.UIThread.Post(() => IsConnected = true);
await SeedActiveTasksAsync();
}
catch
{
Dispatcher.UIThread.Post(() => IsConnected = false);
}
}
public async Task StopAsync()
{
try { await _hub.StopAsync(); } catch { /* swallow */ }
}
public async Task RunNowAsync(string taskId)
{
await _hub.InvokeAsync("RunNow", taskId);
}
public async Task CancelTaskAsync(string taskId)
{
await _hub.InvokeAsync("CancelTask", taskId);
}
public async Task WakeQueueAsync()
{
await _hub.InvokeAsync("WakeQueue");
}
private async Task SeedActiveTasksAsync()
{
try
{
var active = await _hub.InvokeAsync<List<ActiveTaskDto>>("GetActive");
Dispatcher.UIThread.Post(() =>
{
ActiveTasks.Clear();
foreach (var a in active)
ActiveTasks.Add(new ActiveTask(a.Slot, a.TaskId, a.StartedAt));
});
}
catch
{
// Worker might not support GetActive yet
}
}
public async ValueTask DisposeAsync()
{
await _hub.DisposeAsync();
}
// DTO for deserializing the GetActive response
private sealed class ActiveTaskDto
{
public string Slot { get; set; } = "";
public string TaskId { get; set; } = "";
public DateTime StartedAt { get; set; }
}
}