feat(ui): live task updates from worker events + planning polish

Wire TasksIslandViewModel to TaskUpdated/WorktreeUpdated/TaskMessage worker
events so rows refresh without a full reload; add ForegroundHelper to permit
wt.exe to take foreground on planning launch; misc UI polish on lists, task
rows and settings modal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-04-24 11:12:27 +02:00
parent e455d85578
commit b7c60f5838
18 changed files with 200 additions and 56 deletions

View File

@@ -49,6 +49,70 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
{
_dbFactory = dbFactory;
_worker = worker;
if (_worker is not null)
{
_worker.TaskUpdatedEvent += OnWorkerTaskUpdated;
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
_worker.TaskMessageEvent += OnWorkerTaskMessage;
}
}
private void OnWorkerTaskMessage(string taskId, string line)
{
var row = Items.FirstOrDefault(r => r.Id == taskId);
if (row is not null) row.LiveTail = line;
}
private async void OnWorkerTaskUpdated(string taskId)
{
var list = _currentList;
if (list is null) return;
try
{
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks
.Include(t => t.List)
.Include(t => t.Worktree)
.FirstOrDefaultAsync(t => t.Id == taskId);
var existing = Items.FirstOrDefault(r => r.Id == taskId);
if (entity is null)
{
if (existing is not null) Items.Remove(existing);
}
else
{
var matches = TaskMatchesList(entity, list);
if (existing is not null && matches) existing.UpdateFromEntity(entity);
else if (existing is not null) Items.Remove(existing);
else if (matches) { LoadForList(list); return; }
else return;
}
Regroup();
UpdateSubtitle();
}
catch { }
}
private static bool TaskMatchesList(TaskEntity t, ListNavItemViewModel list) => list.Kind switch
{
ListKind.Smart when list.Id == "smart:my-day" => t.IsMyDay,
ListKind.Smart when list.Id == "smart:important" => t.IsStarred,
ListKind.Smart when list.Id == "smart:planned" => t.ScheduledFor != null,
ListKind.Virtual when list.Id == "virtual:queued" => t.Status == TaskStatus.Queued,
ListKind.Virtual when list.Id == "virtual:running" => t.Status == TaskStatus.Running,
ListKind.Virtual when list.Id == "virtual:review" => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active,
ListKind.User => $"user:{t.ListId}" == list.Id,
_ => false,
};
private void OnCurrentListPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(ListNavItemViewModel.Name) && sender is ListNavItemViewModel vm)
HeaderTitle = vm.Name;
}
public void LoadForList(ListNavItemViewModel? list)
@@ -58,7 +122,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
_loadCts = new CancellationTokenSource();
var ct = _loadCts.Token;
if (_currentList is not null)
_currentList.PropertyChanged -= OnCurrentListPropertyChanged;
_currentList = list;
if (_currentList is not null)
_currentList.PropertyChanged += OnCurrentListPropertyChanged;
Items.Clear();
OverdueItems.Clear();
OpenItems.Clear();
@@ -385,6 +454,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
private async Task OpenPlanningSessionAsync(TaskRowViewModel? row)
{
if (row is null || row.Status != TaskStatus.Manual) return;
ForegroundHelper.AllowAny();
try { await _worker!.StartPlanningSessionAsync(row.Id); }
catch { }
}
@@ -412,6 +482,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
switch (choice)
{
case UnfinishedPlanningModalResult.Resume:
ForegroundHelper.AllowAny();
await _worker.ResumePlanningSessionAsync(row.Id);
break;
case UnfinishedPlanningModalResult.FinalizeNow: