From 229d4bbb2bf3ba648844b2968054a0914da3f0b6 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 18:36:55 +0200 Subject: [PATCH 1/9] feat(ui): TaskRowViewModel gains planning hierarchy flags Adds ParentTaskId, IsExpanded, IsChild, IsPlanningParent, IsDraft, and PlanningBadge to TaskRowViewModel with property-changed notifications. Co-Authored-By: Claude Sonnet 4.6 --- .../ViewModels/Islands/TaskRowViewModel.cs | 19 ++++++++ .../UiVm/TaskRowViewModelPlanningTests.cs | 45 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs index 2fe8ddc..f147261 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs @@ -23,6 +23,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase [ObservableProperty] private int _diffDeletions; [ObservableProperty] private bool _dropHintAbove; [ObservableProperty] private bool _dropHintBelow; + [ObservableProperty] private string? _parentTaskId; + [ObservableProperty] private bool _isExpanded = true; public DateTime CreatedAt { get; init; } public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}"; @@ -31,6 +33,17 @@ public sealed partial class TaskRowViewModel : ViewModelBase public int StepsCount { get; init; } public int StepsCompleted { get; init; } + public bool IsChild => !string.IsNullOrEmpty(ParentTaskId); + public bool IsPlanningParent => Status == TaskStatus.Planning || Status == TaskStatus.Planned; + public bool IsDraft => Status == TaskStatus.Draft; + + public string? PlanningBadge => Status switch + { + TaskStatus.Planning => "PLANNING", + TaskStatus.Planned => "PLANNED", + _ => null, + }; + public bool HasBranch => !string.IsNullOrWhiteSpace(Branch); public bool HasDiff => DiffAdditions > 0 || DiffDeletions > 0; public bool HasTags => Tags.Count > 0; @@ -60,8 +73,13 @@ public sealed partial class TaskRowViewModel : ViewModelBase OnPropertyChanged(nameof(IsRunning)); OnPropertyChanged(nameof(IsQueued)); OnPropertyChanged(nameof(HasLiveTail)); + OnPropertyChanged(nameof(IsPlanningParent)); + OnPropertyChanged(nameof(PlanningBadge)); + OnPropertyChanged(nameof(IsDraft)); } + partial void OnParentTaskIdChanged(string? value) => OnPropertyChanged(nameof(IsChild)); + partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch)); partial void OnLiveTailChanged(string? value) => OnPropertyChanged(nameof(HasLiveTail)); partial void OnDoneChanged(bool value) => OnPropertyChanged(nameof(IsOverdue)); @@ -91,6 +109,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase DiffAdditions = add, DiffDeletions = del, CreatedAt = t.CreatedAt, + ParentTaskId = t.ParentTaskId, }; } diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs new file mode 100644 index 0000000..8ac6d38 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs @@ -0,0 +1,45 @@ +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.ViewModels.Islands; +using Xunit; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.UiVm; + +public class TaskRowViewModelPlanningTests +{ + private static TaskRowViewModel MakeRow(TaskStatus status, string? parentTaskId = null) + => new TaskRowViewModel { Id = "t", Status = status, ParentTaskId = parentTaskId }; + + [Fact] + public void Draft_Status_SetsIsChildFlag_WhenParentIdIsNotNull() + { + var vm = MakeRow(TaskStatus.Draft, "parent-id"); + Assert.True(vm.IsChild); + Assert.False(vm.IsPlanningParent); + } + + [Fact] + public void Planning_Status_SetsIsPlanningParent() + { + var vm = MakeRow(TaskStatus.Planning); + Assert.True(vm.IsPlanningParent); + Assert.False(vm.IsChild); + Assert.Equal("PLANNING", vm.PlanningBadge); + } + + [Fact] + public void Planned_Status_ShowsPlannedBadge() + { + var vm = MakeRow(TaskStatus.Planned); + Assert.True(vm.IsPlanningParent); + Assert.Equal("PLANNED", vm.PlanningBadge); + } + + [Fact] + public void NonPlanningStatus_NoBadge() + { + var vm = MakeRow(TaskStatus.Manual); + Assert.False(vm.IsPlanningParent); + Assert.Null(vm.PlanningBadge); + } +} From 00608401aa698a69d4fbce2eb7d1459dabf2429b Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 18:41:04 +0200 Subject: [PATCH 2/9] feat(ui): WorkerClient planning-session methods --- src/ClaudeDo.Ui/Services/PlanningDtos.cs | 18 ++++++++++++++++++ src/ClaudeDo.Ui/Services/WorkerClient.cs | 15 +++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 src/ClaudeDo.Ui/Services/PlanningDtos.cs diff --git a/src/ClaudeDo.Ui/Services/PlanningDtos.cs b/src/ClaudeDo.Ui/Services/PlanningDtos.cs new file mode 100644 index 0000000..71a553e --- /dev/null +++ b/src/ClaudeDo.Ui/Services/PlanningDtos.cs @@ -0,0 +1,18 @@ +namespace ClaudeDo.Ui.Services; + +public sealed record PlanningSessionFilesDto( + string SessionDirectory, + string McpConfigPath, + string SystemPromptPath, + string InitialPromptPath); + +public sealed record PlanningSessionStartInfo( + string ParentTaskId, + string WorkingDir, + PlanningSessionFilesDto Files); + +public sealed record PlanningSessionResumeInfo( + string ParentTaskId, + string WorkingDir, + string ClaudeSessionId, + string McpConfigPath); diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 0612c37..56775c3 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -347,6 +347,21 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable } } + public async Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) + => await _hub.InvokeAsync("StartPlanningSessionAsync", taskId, ct); + + public async Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) + => await _hub.InvokeAsync("ResumePlanningSessionAsync", taskId, ct); + + public async Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) + => await _hub.InvokeAsync("DiscardPlanningSessionAsync", taskId, ct); + + public async Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) + => await _hub.InvokeAsync("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct); + + public async Task GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) + => await _hub.InvokeAsync("GetPendingDraftCountAsync", taskId, ct); + // DTOs for deserializing hub responses private sealed class ActiveTaskDto { From 309f84b388e4bbdd2b7f9a8b0cac5b891c5b0601 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 18:51:22 +0200 Subject: [PATCH 3/9] feat(ui): planning commands and expand/collapse in TasksIslandViewModel - Add IWorkerClient interface; WorkerClient implements it - TasksIslandViewModel accepts IWorkerClient? and gains OpenPlanningSession, ResumePlanningSession, DiscardPlanningSession, FinalizePlanningSession, and ToggleExpand commands - Regroup() is hierarchy-aware: children of collapsed planning parents are hidden - InternalsVisibleTo ClaudeDo.Worker.Tests for Regroup() - 4 new unit tests covering collapse/expand and guard logic Co-Authored-By: Claude Sonnet 4.6 --- src/ClaudeDo.Ui/ClaudeDo.Ui.csproj | 1 + src/ClaudeDo.Ui/Services/IWorkerClient.cs | 10 ++ src/ClaudeDo.Ui/Services/WorkerClient.cs | 12 +- .../Islands/TasksIslandViewModel.cs | 72 +++++++++- .../UiVm/TasksIslandViewModelPlanningTests.cs | 129 ++++++++++++++++++ 5 files changed, 219 insertions(+), 5 deletions(-) create mode 100644 src/ClaudeDo.Ui/Services/IWorkerClient.cs create mode 100644 tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs diff --git a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj index 2ea6f07..7a19886 100644 --- a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj +++ b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj @@ -15,6 +15,7 @@ + diff --git a/src/ClaudeDo.Ui/Services/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/IWorkerClient.cs new file mode 100644 index 0000000..a904c50 --- /dev/null +++ b/src/ClaudeDo.Ui/Services/IWorkerClient.cs @@ -0,0 +1,10 @@ +namespace ClaudeDo.Ui.Services; + +public interface IWorkerClient +{ + Task WakeQueueAsync(); + Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default); + Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default); + Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default); + Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default); +} diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 56775c3..57566e6 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -25,7 +25,7 @@ sealed class IndefiniteRetryPolicy : IRetryPolicy _delays[Math.Min(retryContext.PreviousRetryCount, _delays.Length - 1)]; } -public partial class WorkerClient : ObservableObject, IAsyncDisposable +public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerClient { private readonly HubConnection _hub; private CancellationTokenSource? _startCts; @@ -362,6 +362,16 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable public async Task GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => await _hub.InvokeAsync("GetPendingDraftCountAsync", taskId, ct); + // IWorkerClient explicit implementations (drop typed return values) + async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct) + => await StartPlanningSessionAsync(taskId, ct); + async Task IWorkerClient.ResumePlanningSessionAsync(string taskId, CancellationToken ct) + => await ResumePlanningSessionAsync(taskId, ct); + async Task IWorkerClient.DiscardPlanningSessionAsync(string taskId, CancellationToken ct) + => await DiscardPlanningSessionAsync(taskId, ct); + async Task IWorkerClient.FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks, CancellationToken ct) + => await FinalizePlanningSessionAsync(taskId, queueAgentTasks, ct); + // DTOs for deserializing hub responses private sealed class ActiveTaskDto { diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index 904a654..4111a86 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -13,7 +13,8 @@ namespace ClaudeDo.Ui.ViewModels.Islands; public sealed partial class TasksIslandViewModel : ViewModelBase { private readonly IDbContextFactory _dbFactory; - private readonly WorkerClient? _worker; + private readonly IWorkerClient? _worker; + private readonly Dictionary _expandedState = new(); private ListNavItemViewModel? _currentList; private CancellationTokenSource? _loadCts; @@ -41,7 +42,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase [ObservableProperty] private bool _showOpenLabel; [ObservableProperty] private string _completedHeader = "COMPLETED"; - public TasksIslandViewModel(IDbContextFactory dbFactory, WorkerClient? worker = null) + public TasksIslandViewModel(IDbContextFactory dbFactory, IWorkerClient? worker = null) { _dbFactory = dbFactory; _worker = worker; @@ -105,14 +106,35 @@ public sealed partial class TasksIslandViewModel : ViewModelBase catch (OperationCanceledException) { } } - private void Regroup() + internal void Regroup() { OverdueItems.Clear(); OpenItems.Clear(); CompletedItems.Clear(); - var today = DateTime.Today; + // Restore IsExpanded from saved state foreach (var r in Items) + { + if (_expandedState.TryGetValue(r.Id, out var saved)) + r.IsExpanded = saved; + } + + // Build hierarchy-aware flat list: top-level rows interleaved with visible children. + // Items is already ordered by SortOrder from the DB query. + var topLevel = Items.Where(r => !r.IsChild); + var flat = new List(); + foreach (var parent in topLevel) + { + flat.Add(parent); + if (parent.IsPlanningParent && parent.IsExpanded) + { + var children = Items.Where(r => r.ParentTaskId == parent.Id); + flat.AddRange(children); + } + } + + var today = DateTime.Today; + foreach (var r in flat) { if (r.Done) CompletedItems.Add(r); @@ -356,6 +378,48 @@ public sealed partial class TasksIslandViewModel : ViewModelBase [RelayCommand] private void OpenListSettings() => OpenListSettingsRequested?.Invoke(this, EventArgs.Empty); + [RelayCommand] + private async Task OpenPlanningSessionAsync(TaskRowViewModel? row) + { + if (row is null || row.Status != TaskStatus.Manual) return; + try { await _worker!.StartPlanningSessionAsync(row.Id); } + catch { } + } + + [RelayCommand] + private async Task ResumePlanningSessionAsync(TaskRowViewModel? row) + { + if (row is null || !row.IsPlanningParent) return; + try { await _worker!.ResumePlanningSessionAsync(row.Id); } + catch { } + } + + [RelayCommand] + private async Task DiscardPlanningSessionAsync(TaskRowViewModel? row) + { + if (row is null) return; + try { await _worker!.DiscardPlanningSessionAsync(row.Id); } + catch { } + } + + [RelayCommand] + private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row) + { + if (row is null) return; + try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: true); } + catch { } + } + + [RelayCommand] + private void ToggleExpand(TaskRowViewModel? row) + { + if (row is null) return; + var next = !(_expandedState.TryGetValue(row.Id, out var current) ? current : row.IsExpanded); + _expandedState[row.Id] = next; + row.IsExpanded = next; + Regroup(); + } + partial void OnSelectedTaskChanged(TaskRowViewModel? value) { foreach (var i in Items) i.IsSelected = ReferenceEquals(i, value); diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs new file mode 100644 index 0000000..ea9b67f --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -0,0 +1,129 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Islands; +using CommunityToolkit.Mvvm.Input; +using Microsoft.EntityFrameworkCore; +using Xunit; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.UiVm; + +// ── Fake worker client ──────────────────────────────────────────────────────── + +sealed class FakeWorkerClient : IWorkerClient +{ + public int StartPlanningCalls { get; private set; } + public int ResumePlanningCalls { get; private set; } + public int DiscardPlanningCalls { get; private set; } + public int FinalizePlanningCalls { get; private set; } + public int WakeQueueCalls { get; private set; } + + public Task WakeQueueAsync() { WakeQueueCalls++; return Task.CompletedTask; } + public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) { StartPlanningCalls++; return Task.CompletedTask; } + public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) { ResumePlanningCalls++; return Task.CompletedTask; } + public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) { DiscardPlanningCalls++; return Task.CompletedTask; } + public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) { FinalizePlanningCalls++; return Task.CompletedTask; } +} + +// ── Helper to build VM with pre-seeded Items ────────────────────────────────── + +file static class VmFactory +{ + // Minimal SQLite :memory: factory — never actually called in these tests + // (we seed Items directly), but required by the VM constructor. + private static IDbContextFactory NullDbFactory() + { + var opts = new DbContextOptionsBuilder() + .UseSqlite("DataSource=:memory:") + .Options; + return new NullDbContextFactory(opts); + } + + private sealed class NullDbContextFactory(DbContextOptions opts) + : IDbContextFactory + { + public ClaudeDoDbContext CreateDbContext() => new(opts); + } + + public static (TasksIslandViewModel vm, FakeWorkerClient worker) Create( + IEnumerable rows) + { + var worker = new FakeWorkerClient(); + var vm = new TasksIslandViewModel(NullDbFactory(), worker); + foreach (var r in rows) + vm.Items.Add(r); + vm.Regroup(); + return (vm, worker); + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +public class TasksIslandViewModelPlanningTests +{ + private static TaskRowViewModel MakeRow(string id, TaskStatus status, string? parentId = null, int sortOrder = 0) + => new TaskRowViewModel { Id = id, Status = status, ParentTaskId = parentId }; + + [Fact] + public void ToggleExpand_CollapsesChildrenOfPlanningParent() + { + var parent = MakeRow("p1", TaskStatus.Planning); + var child1 = MakeRow("c1", TaskStatus.Draft, "p1"); + var child2 = MakeRow("c2", TaskStatus.Draft, "p1"); + + var (vm, _) = VmFactory.Create([parent, child1, child2]); + + // Initially expanded — children visible in OpenItems + Assert.Contains(child1, vm.OpenItems); + Assert.Contains(child2, vm.OpenItems); + + // Collapse the parent + vm.ToggleExpandCommand.Execute(parent); + + // Children should no longer appear + Assert.DoesNotContain(child1, vm.OpenItems); + Assert.DoesNotContain(child2, vm.OpenItems); + // Parent still present + Assert.Contains(parent, vm.OpenItems); + } + + [Fact] + public async Task OpenPlanningSession_IgnoresNonManualRow() + { + var row = MakeRow("t1", TaskStatus.Queued); + var (vm, worker) = VmFactory.Create([row]); + + await ((IAsyncRelayCommand)vm.OpenPlanningSessionCommand).ExecuteAsync(row); + + Assert.Equal(0, worker.StartPlanningCalls); + } + + [Fact] + public async Task OpenPlanningSession_CallsWorkerForManualRow() + { + var row = MakeRow("t1", TaskStatus.Manual); + var (vm, worker) = VmFactory.Create([row]); + + await ((IAsyncRelayCommand)vm.OpenPlanningSessionCommand).ExecuteAsync(row); + + Assert.Equal(1, worker.StartPlanningCalls); + } + + [Fact] + public void ToggleExpand_ExpandsCollapsedParentAgain() + { + var parent = MakeRow("p1", TaskStatus.Planned); + var child = MakeRow("c1", TaskStatus.Draft, "p1"); + + var (vm, _) = VmFactory.Create([parent, child]); + + // Collapse + vm.ToggleExpandCommand.Execute(parent); + Assert.DoesNotContain(child, vm.OpenItems); + + // Re-expand + vm.ToggleExpandCommand.Execute(parent); + Assert.Contains(child, vm.OpenItems); + } +} From 42b208ff285ff496d58fdef51c27ff6d216b1c47 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 18:58:08 +0200 Subject: [PATCH 4/9] feat(ui): TaskRowView hierarchy indentation, chevron, badges, draft italic Co-Authored-By: Claude Sonnet 4.6 --- src/ClaudeDo.App/App.axaml | 4 +- .../Converters/BoolToDraftOpacityConverter.cs | 15 + .../Converters/BoolToItalicConverter.cs | 16 + .../Views/Islands/TaskRowView.axaml | 276 ++++++++++-------- 4 files changed, 193 insertions(+), 118 deletions(-) create mode 100644 src/ClaudeDo.Ui/Converters/BoolToDraftOpacityConverter.cs create mode 100644 src/ClaudeDo.Ui/Converters/BoolToItalicConverter.cs diff --git a/src/ClaudeDo.App/App.axaml b/src/ClaudeDo.App/App.axaml index 0c7445f..e7e3f98 100644 --- a/src/ClaudeDo.App/App.axaml +++ b/src/ClaudeDo.App/App.axaml @@ -17,7 +17,9 @@ - + + + diff --git a/src/ClaudeDo.Ui/Converters/BoolToDraftOpacityConverter.cs b/src/ClaudeDo.Ui/Converters/BoolToDraftOpacityConverter.cs new file mode 100644 index 0000000..e2d8d04 --- /dev/null +++ b/src/ClaudeDo.Ui/Converters/BoolToDraftOpacityConverter.cs @@ -0,0 +1,15 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace ClaudeDo.Ui.Converters; + +public sealed class BoolToDraftOpacityConverter : IValueConverter +{ + public static BoolToDraftOpacityConverter Instance { get; } = new(); + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is true ? 0.7 : 1.0; + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/src/ClaudeDo.Ui/Converters/BoolToItalicConverter.cs b/src/ClaudeDo.Ui/Converters/BoolToItalicConverter.cs new file mode 100644 index 0000000..0017101 --- /dev/null +++ b/src/ClaudeDo.Ui/Converters/BoolToItalicConverter.cs @@ -0,0 +1,16 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace ClaudeDo.Ui.Converters; + +public sealed class BoolToItalicConverter : IValueConverter +{ + public static BoolToItalicConverter Instance { get; } = new(); + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is true ? FontStyle.Italic : FontStyle.Normal; + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml index f7a7ed1..c823226 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml @@ -15,134 +15,176 @@ Background="{DynamicResource MossBrush}" CornerRadius="1" IsVisible="{Binding DropHintAbove}"/> - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + - - - + + + + + + + - - + + + + + + + - - - - + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - + From 388a8c1fae5eb54b206dcf8d8f6a8b96d8afeebd Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 19:02:06 +0200 Subject: [PATCH 5/9] feat(ui): planning entries in task context menu --- .../ViewModels/Islands/TaskRowViewModel.cs | 11 ++++++++++- src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml | 13 +++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs index f147261..3d4a5bc 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs @@ -37,6 +37,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase public bool IsPlanningParent => Status == TaskStatus.Planning || Status == TaskStatus.Planned; public bool IsDraft => Status == TaskStatus.Draft; + public bool CanOpenPlanningSession => Status == TaskStatus.Manual && !IsChild; + public bool CanResumeOrDiscardPlanning => Status == TaskStatus.Planning; + public string? PlanningBadge => Status switch { TaskStatus.Planning => "PLANNING", @@ -76,9 +79,15 @@ public sealed partial class TaskRowViewModel : ViewModelBase OnPropertyChanged(nameof(IsPlanningParent)); OnPropertyChanged(nameof(PlanningBadge)); OnPropertyChanged(nameof(IsDraft)); + OnPropertyChanged(nameof(CanOpenPlanningSession)); + OnPropertyChanged(nameof(CanResumeOrDiscardPlanning)); } - partial void OnParentTaskIdChanged(string? value) => OnPropertyChanged(nameof(IsChild)); + partial void OnParentTaskIdChanged(string? value) + { + OnPropertyChanged(nameof(IsChild)); + OnPropertyChanged(nameof(CanOpenPlanningSession)); + } partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch)); partial void OnLiveTailChanged(string? value) => OnPropertyChanged(nameof(HasLiveTail)); diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml index c823226..1e4bdb1 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml @@ -38,6 +38,19 @@ IsVisible="{Binding IsQueued}" Click="OnRemoveFromQueueClick"/> + + + + Date: Thu, 23 Apr 2026 19:04:26 +0200 Subject: [PATCH 6/9] feat(ui): draft and planning badge styles --- src/ClaudeDo.Ui/Design/IslandStyles.axaml | 32 +++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/ClaudeDo.Ui/Design/IslandStyles.axaml b/src/ClaudeDo.Ui/Design/IslandStyles.axaml index 0cd8837..5cbc092 100644 --- a/src/ClaudeDo.Ui/Design/IslandStyles.axaml +++ b/src/ClaudeDo.Ui/Design/IslandStyles.axaml @@ -84,6 +84,11 @@ M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.03 7.03 0 0 0-1.69-.98l-.38-2.65a.5.5 0 0 0-.5-.42h-4a.5.5 0 0 0-.5.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.5.5 0 0 0 .12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65a.5.5 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65a.5.5 0 0 0 .5.42h4a.5.5 0 0 0 .5-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.5.5 0 0 0-.12-.64l-2.11-1.65z + + + + + @@ -866,4 +871,31 @@ + + + + + + + + + + + + + From 47b49743c073c689b9491cd42541c6f37db69c01 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 19:12:59 +0200 Subject: [PATCH 7/9] feat(ui): unfinished planning session dialog Co-Authored-By: Claude Sonnet 4.6 --- src/ClaudeDo.Ui/Services/IWorkerClient.cs | 1 + src/ClaudeDo.Ui/Services/WorkerClient.cs | 2 + .../Islands/TasksIslandViewModel.cs | 36 +++++++- .../UnfinishedPlanningModalViewModel.cs | 27 ++++++ .../Views/Islands/TasksIslandView.axaml.cs | 14 ++++ .../Modals/UnfinishedPlanningModalView.axaml | 82 +++++++++++++++++++ .../UnfinishedPlanningModalView.axaml.cs | 23 ++++++ .../UiVm/TasksIslandViewModelPlanningTests.cs | 1 + 8 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 src/ClaudeDo.Ui/ViewModels/Modals/UnfinishedPlanningModalViewModel.cs create mode 100644 src/ClaudeDo.Ui/Views/Modals/UnfinishedPlanningModalView.axaml create mode 100644 src/ClaudeDo.Ui/Views/Modals/UnfinishedPlanningModalView.axaml.cs diff --git a/src/ClaudeDo.Ui/Services/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/IWorkerClient.cs index a904c50..08cf2fc 100644 --- a/src/ClaudeDo.Ui/Services/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/IWorkerClient.cs @@ -7,4 +7,5 @@ public interface IWorkerClient Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default); Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default); Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default); + Task GetPendingDraftCountAsync(string taskId, CancellationToken ct = default); } diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 57566e6..a330714 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -371,6 +371,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC => await DiscardPlanningSessionAsync(taskId, ct); async Task IWorkerClient.FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks, CancellationToken ct) => await FinalizePlanningSessionAsync(taskId, queueAgentTasks, ct); + async Task IWorkerClient.GetPendingDraftCountAsync(string taskId, CancellationToken ct) + => await GetPendingDraftCountAsync(taskId, ct); // DTOs for deserializing hub responses private sealed class ActiveTaskDto diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index 4111a86..baa02c9 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -5,6 +5,7 @@ using CommunityToolkit.Mvvm.Input; using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Modals; using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; @@ -42,6 +43,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase [ObservableProperty] private bool _showOpenLabel; [ObservableProperty] private string _completedHeader = "COMPLETED"; + public Func? ShowUnfinishedPlanningModal { get; set; } + public TasksIslandViewModel(IDbContextFactory dbFactory, IWorkerClient? worker = null) { _dbFactory = dbFactory; @@ -390,7 +393,38 @@ public sealed partial class TasksIslandViewModel : ViewModelBase private async Task ResumePlanningSessionAsync(TaskRowViewModel? row) { if (row is null || !row.IsPlanningParent) return; - try { await _worker!.ResumePlanningSessionAsync(row.Id); } + if (_worker is null) return; + try + { + var draftCount = await _worker.GetPendingDraftCountAsync(row.Id); + var modalVm = new UnfinishedPlanningModalViewModel + { + TaskTitle = row.Title, + DraftCount = draftCount, + }; + + if (ShowUnfinishedPlanningModal is null) + return; + await ShowUnfinishedPlanningModal(modalVm); + + var choice = await modalVm.Result.Task; + + switch (choice) + { + case UnfinishedPlanningModalResult.Resume: + await _worker.ResumePlanningSessionAsync(row.Id); + break; + case UnfinishedPlanningModalResult.FinalizeNow: + await _worker.FinalizePlanningSessionAsync(row.Id); + break; + case UnfinishedPlanningModalResult.Discard: + await _worker.DiscardPlanningSessionAsync(row.Id); + break; + case UnfinishedPlanningModalResult.Cancel: + default: + break; + } + } catch { } } diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/UnfinishedPlanningModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/UnfinishedPlanningModalViewModel.cs new file mode 100644 index 0000000..d0f23a4 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Modals/UnfinishedPlanningModalViewModel.cs @@ -0,0 +1,27 @@ +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClaudeDo.Ui.ViewModels.Modals; + +public enum UnfinishedPlanningModalResult +{ + Cancel, + Resume, + FinalizeNow, + Discard, +} + +public sealed partial class UnfinishedPlanningModalViewModel : ViewModelBase +{ + [ObservableProperty] private string _taskTitle = ""; + [ObservableProperty] private int _draftCount; + + public TaskCompletionSource Result { get; } = new(); + public Action? CloseAction { get; set; } + + [RelayCommand] private void Resume() { Result.TrySetResult(UnfinishedPlanningModalResult.Resume); CloseAction?.Invoke(); } + [RelayCommand] private void FinalizeNow() { Result.TrySetResult(UnfinishedPlanningModalResult.FinalizeNow); CloseAction?.Invoke(); } + [RelayCommand] private void Discard() { Result.TrySetResult(UnfinishedPlanningModalResult.Discard); CloseAction?.Invoke(); } + [RelayCommand] private void Cancel() { Result.TrySetResult(UnfinishedPlanningModalResult.Cancel); CloseAction?.Invoke(); } +} diff --git a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs index 3137855..882efa1 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs @@ -4,6 +4,8 @@ using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.VisualTree; using ClaudeDo.Ui.ViewModels.Islands; +using ClaudeDo.Ui.ViewModels.Modals; +using ClaudeDo.Ui.Views.Modals; namespace ClaudeDo.Ui.Views.Islands; @@ -19,7 +21,19 @@ public partial class TasksIslandView : UserControl DataContextChanged += (_, _) => { if (DataContext is TasksIslandViewModel vm) + { vm.FocusAddTaskRequested += (_, _) => AddTaskBox.Focus(); + vm.ShowUnfinishedPlanningModal = async (modalVm) => + { + var owner = TopLevel.GetTopLevel(this) as Window; + if (owner is null) { modalVm.CancelCommand.Execute(null); return; } + var modal = new UnfinishedPlanningModalView { DataContext = modalVm }; + // Closing via the OS title-bar (if ever enabled) also resolves the TCS. + modal.Closed += (_, _) => modalVm.CancelCommand.Execute(null); + await modal.ShowDialog(owner); + // ShowDialog completes once the window is closed (CloseAction or OS close). + }; + } }; } diff --git a/src/ClaudeDo.Ui/Views/Modals/UnfinishedPlanningModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/UnfinishedPlanningModalView.axaml new file mode 100644 index 0000000..7e02abd --- /dev/null +++ b/src/ClaudeDo.Ui/Views/Modals/UnfinishedPlanningModalView.axaml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + +