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

@@ -0,0 +1,19 @@
using System.Runtime.InteropServices;
namespace ClaudeDo.Ui.Services;
internal static class ForegroundHelper
{
private const int ASFW_ANY = -1;
[DllImport("user32.dll", SetLastError = true)]
private static extern bool AllowSetForegroundWindow(int dwProcessId);
// Grants any process the right to take foreground on next SetForegroundWindow call.
// Used before RPCs that cause a helper process (e.g. wt.exe) to spawn a new window.
public static void AllowAny()
{
if (!OperatingSystem.IsWindows()) return;
try { AllowSetForegroundWindow(ASFW_ANY); } catch { }
}
}

View File

@@ -2,6 +2,10 @@ namespace ClaudeDo.Ui.Services;
public interface IWorkerClient
{
event Action<string>? TaskUpdatedEvent;
event Action<string>? WorktreeUpdatedEvent;
event Action<string, string>? TaskMessageEvent;
Task WakeQueueAsync();
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);

View File

@@ -5,8 +5,8 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
public sealed partial class ListNavItemViewModel : ViewModelBase
{
public required string Id { get; init; }
public required string Name { get; init; }
public required ListKind Kind { get; init; }
[ObservableProperty] private string _name = "";
[ObservableProperty] private int _count;
[ObservableProperty] private bool _isActive;
[ObservableProperty] private string? _workingDir;

View File

@@ -40,8 +40,9 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
private async System.Threading.Tasks.Task OpenListSettingsAsync(ListNavItemViewModel? row)
{
if (row is null || ShowListSettingsModal is null || _services is null) return;
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
await vm.LoadAsync(row.Id, row.Name, row.WorkingDir, row.DefaultCommitType);
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType);
await ShowListSettingsModal(vm);
await RefreshRowAsync(row.Id);
}
@@ -169,6 +170,46 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
[RelayCommand]
private void Select(ListNavItemViewModel item) => SelectedList = item;
[RelayCommand]
private async Task CreateListAsync()
{
var entity = new ListEntity
{
Id = Guid.NewGuid().ToString("N"),
Name = "New list",
DefaultCommitType = "chore",
CreatedAt = DateTime.UtcNow,
};
await using (var ctx = await _dbFactory.CreateDbContextAsync())
{
var lists = new ListRepository(ctx);
await lists.AddAsync(entity);
}
var item = new ListNavItemViewModel
{
Id = $"user:{entity.Id}",
Name = entity.Name,
Kind = ListKind.User,
IconKey = "Folder",
DotColorKey = "Moss",
WorkingDir = entity.WorkingDir,
DefaultCommitType = entity.DefaultCommitType,
};
Items.Add(item);
UserLists.Add(item);
SelectedList = item;
if (ShowListSettingsModal is not null && _services is not null)
{
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType);
await ShowListSettingsModal(vm);
await RefreshRowAsync(item.Id);
}
}
partial void OnSelectedListChanged(ListNavItemViewModel? value)
{
foreach (var i in Items) i.IsActive = ReferenceEquals(i, value);
@@ -188,6 +229,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
var entity = await lists.GetByIdAsync(rawId);
if (entity is null) return;
row.Name = entity.Name;
row.WorkingDir = entity.WorkingDir;
row.DefaultCommitType = entity.DefaultCommitType;
}

View File

@@ -101,25 +101,27 @@ public sealed partial class TaskRowViewModel : ViewModelBase
partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); }
public static TaskRowViewModel FromEntity(TaskEntity t)
{
var row = new TaskRowViewModel { Id = t.Id, CreatedAt = t.CreatedAt };
row.UpdateFromEntity(t);
return row;
}
public void UpdateFromEntity(TaskEntity t)
{
var (add, del) = ParseDiffStat(t.Worktree?.DiffStat);
return new TaskRowViewModel
{
Id = t.Id,
Title = t.Title,
ListName = t.List?.Name ?? "",
Done = t.Status == TaskStatus.Done,
IsStarred = t.IsStarred,
IsMyDay = t.IsMyDay,
Status = t.Status,
Branch = t.Worktree?.BranchName,
DiffStat = t.Worktree?.DiffStat,
ScheduledFor = t.ScheduledFor,
DiffAdditions = add,
DiffDeletions = del,
CreatedAt = t.CreatedAt,
ParentTaskId = t.ParentTaskId,
};
Title = t.Title;
ListName = t.List?.Name ?? "";
Done = t.Status == TaskStatus.Done;
IsStarred = t.IsStarred;
IsMyDay = t.IsMyDay;
Status = t.Status;
Branch = t.Worktree?.BranchName;
DiffStat = t.Worktree?.DiffStat;
ScheduledFor = t.ScheduledFor;
DiffAdditions = add;
DiffDeletions = del;
ParentTaskId = t.ParentTaskId;
}
// Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 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:

View File

@@ -166,7 +166,8 @@
</ItemsControl>
<!-- + New list button -->
<Button Classes="new-list-btn" Margin="0,4,0,0">
<Button Classes="new-list-btn" Margin="0,4,0,0"
Command="{Binding CreateListCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<PathIcon Data="{StaticResource Icon.Plus}"
Width="13" Height="13"

View File

@@ -39,16 +39,13 @@
Click="OnRemoveFromQueueClick"/>
<Separator/>
<MenuItem Header="Open planning Session"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).OpenPlanningSessionCommand}"
CommandParameter="{Binding}"
Click="OnOpenPlanningSessionClick"
IsVisible="{Binding CanOpenPlanningSession}"/>
<MenuItem Header="Resume planning Session"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ResumePlanningSessionCommand}"
CommandParameter="{Binding}"
Click="OnResumePlanningSessionClick"
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
<MenuItem Header="Discard planning session"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).DiscardPlanningSessionCommand}"
CommandParameter="{Binding}"
Click="OnDiscardPlanningSessionClick"
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
<Separator/>
<MenuItem Header="Schedule for..." Click="OnScheduleForClick"/>

View File

@@ -39,6 +39,24 @@ public partial class TaskRowView : UserControl
await vm.ClearScheduleCommand.ExecuteAsync(row);
}
private async void OnOpenPlanningSessionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.OpenPlanningSessionCommand.ExecuteAsync(row);
}
private async void OnResumePlanningSessionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.ResumePlanningSessionCommand.ExecuteAsync(row);
}
private async void OnDiscardPlanningSessionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.DiscardPlanningSessionCommand.ExecuteAsync(row);
}
private void OnScheduleForClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not TaskRowViewModel row) return;

View File

@@ -14,6 +14,7 @@
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
<KeyBinding Gesture="Enter" Command="{Binding SaveCommand}"/>
</Window.KeyBindings>
<Window.Styles>
@@ -82,7 +83,7 @@
<StackPanel Spacing="12">
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="Name"/>
<TextBox Text="{Binding Name}" />
<TextBox Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Spacing="4">