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:
154
src/ClaudeDo.Ui/Services/WorkerClient.cs
Normal file
154
src/ClaudeDo.Ui/Services/WorkerClient.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user