From 9159f1b55c417e57bcf095b55ebc48ef8517392f Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 21 Aug 2026 10:27:02 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(ui):=20Empty=20States=20f=C3=BCr=20Det?= =?UTF-8?q?ail-Pane=20und=20Task-Liste=20(UX-Audit=20#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zentrierter, gedimmter Platzhalter (Mission-Control-Muster) im Detail-Pane bei fehlender Selektion (nicht in Notes-/Prep-Modus) und in der Task-Liste bei 0 sichtbaren Tasks; User-Listen ohne WorkingDir bekommen zusätzlich den Repo-Verknüpfungs-Hinweis. --- src/ClaudeDo.Localization/locales/de.json | 7 +- src/ClaudeDo.Localization/locales/en.json | 7 +- .../Islands/DetailsIslandViewModel.cs | 18 ++- .../Islands/TasksIslandViewModel.cs | 30 ++++- .../Views/Islands/DetailsIslandView.axaml | 6 + .../Views/Islands/TasksIslandView.axaml | 122 ++++++++++-------- .../DetailsIslandEmptyStateTests.cs | 116 +++++++++++++++++ .../ViewModels/TasksIslandEmptyStateTests.cs | 112 ++++++++++++++++ 8 files changed, 355 insertions(+), 63 deletions(-) create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandEmptyStateTests.cs create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandEmptyStateTests.cs diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index b1aff8d9..dbbada2e 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -189,7 +189,9 @@ "feedbackPlaceholder": "Was soll der Agent korrigieren?", "rerun": "Erneut ausführen", "refineTip": "Aufgabe mit Claude verfeinern", - "refiningTip": "Claude verfeinert diese Aufgabe…" + "refiningTip": "Claude verfeinert diese Aufgabe…", + "emptyHint": "Noch keine Aufgaben. Oben eine hinzufügen oder in die Warteschlange einreihen, wenn du bereit bist.", + "emptyHintNoWorkingDir": "Verknüpfe ein Repo in den Listeneinstellungen, um Warteschlange, „Claude machen lassen" und die Schnell-Sitzung zu aktivieren." }, "lists": { "heading": "Listen", @@ -254,7 +256,8 @@ "description": "Beschreibung", "steps": "Schritte", "files": "Dateien" - } + }, + "emptyState": "Wähle eine Aufgabe aus, um Details zu sehen" }, "agent": { "openWorktreeTip": "Worktree im Datei-Explorer öffnen — benötigt einen aktiven Worktree", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 3ff9600a..cdd93357 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -189,7 +189,9 @@ "feedbackPlaceholder": "What should the agent fix?", "rerun": "Re-run", "refineTip": "Refine this task with Claude", - "refiningTip": "Claude is refining this task…" + "refiningTip": "Claude is refining this task…", + "emptyHint": "No tasks yet. Add one above, or send it to the queue once you're ready.", + "emptyHintNoWorkingDir": "Link a repo in List Settings to enable the queue, Let Claude handle it, and Quick session." }, "lists": { "heading": "Lists", @@ -254,7 +256,8 @@ "description": "Description", "steps": "Steps", "files": "Files" - } + }, + "emptyState": "Select a task to see its details" }, "agent": { "openWorktreeTip": "Open worktree in file explorer — requires an active worktree", diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 35a9ad1b..dfc74d31 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -44,8 +44,21 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable public bool IsTaskDetailVisible => !IsNotesMode && !IsPrepMode; - partial void OnIsNotesModeChanged(bool value) => OnPropertyChanged(nameof(IsTaskDetailVisible)); - partial void OnIsPrepModeChanged(bool value) => OnPropertyChanged(nameof(IsTaskDetailVisible)); + /// Centered placeholder shown when no task is selected — hidden in Notes/Prep mode, + /// where is already false. + public bool IsEmptyStateVisible => Task is null && IsTaskDetailVisible; + + partial void OnIsNotesModeChanged(bool value) + { + OnPropertyChanged(nameof(IsTaskDetailVisible)); + OnPropertyChanged(nameof(IsEmptyStateVisible)); + } + + partial void OnIsPrepModeChanged(bool value) + { + OnPropertyChanged(nameof(IsTaskDetailVisible)); + OnPropertyChanged(nameof(IsEmptyStateVisible)); + } public NotesEditorViewModel Notes { get; private set; } = null!; @@ -55,6 +68,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable [NotifyCanExecuteChangedFor(nameof(DequeueCommand))] [NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))] [NotifyPropertyChangedFor(nameof(TaskIdBadge))] + [NotifyPropertyChangedFor(nameof(IsEmptyStateVisible))] private TaskRowViewModel? _task; // Editable fields diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index fe896400..e1625721 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -104,16 +104,38 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable [ObservableProperty] private string _statusPill = ""; [ObservableProperty] private bool _hasStatusPill; [ObservableProperty] private bool _isShowingCompleted = true; - [ObservableProperty] private bool _hasOverdue; - [ObservableProperty] private bool _hasOpen; - [ObservableProperty] private bool _hasCompleted; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsTasksEmptyHintVisible))] + [NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))] + private bool _hasOverdue; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsTasksEmptyHintVisible))] + [NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))] + private bool _hasOpen; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsTasksEmptyHintVisible))] + [NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))] + private bool _hasCompleted; [ObservableProperty] private bool _showOpenLabel; [ObservableProperty] private string _completedHeader = ""; [ObservableProperty] private bool _showNotesRow; [ObservableProperty] private bool _isMyDayList; - [ObservableProperty] private bool _isLetClaudeVisible; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))] + private bool _isLetClaudeVisible; [ObservableProperty] private bool _isQuickClaudeVisible; + /// No visible tasks below the add-task row — every item lands in one of + /// Overdue/Open/Completed, so all-false here always means the list has zero items. + public bool IsTasksEmptyHintVisible => !HasOverdue && !HasOpen && !HasCompleted; + + /// Extra empty-state line for a User list with no linked working dir, where the + /// execution features (queue, Let Claude handle it, Quick session) are unavailable. Derived + /// separately from so Smart/Virtual lists — which also have + /// no working dir but aren't missing a repo link — don't pick it up. + public bool IsTasksEmptyRepoHintVisible => + IsTasksEmptyHintVisible && _currentList?.Kind == ListKind.User && !IsLetClaudeVisible; + // Shared by QueuePlanningSubtasksAsync and FinalizePlanningSessionAsync — both are triggered // from a context menu that closes the instant a click lands, so there is no per-row surface // left standing to anchor an indicator to. The island header is the nearest surface still diff --git a/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml index 12d003f1..c423f6dc 100644 --- a/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml @@ -146,6 +146,12 @@ DragCompleted="OnSplitterDragCompleted"/> + + + diff --git a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml index 2d6040a5..08d090a6 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml @@ -97,60 +97,76 @@ Inset lives on the panel, NOT on the ListBox: Avalonia 12 leaves ScrollViewer.Padding out of the Extent, so at max scroll the last row would sit below the viewport and stay unreachable. --> - - - - - - - - - - - - - - - - - + + + + - - - - - - - + + + + + + + + + + diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandEmptyStateTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandEmptyStateTests.cs new file mode 100644 index 00000000..9a1fba1b --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandEmptyStateTests.cs @@ -0,0 +1,116 @@ +using ClaudeDo.Data; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Islands; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +public class DetailsIslandEmptyStateTests : IDisposable +{ + private readonly string _dbPath; + + public DetailsIslandEmptyStateTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_emptystate_test_{Guid.NewGuid():N}.db"); + using var ctx = NewContext(); + ctx.Database.EnsureCreated(); + } + + public void Dispose() + { + try { File.Delete(_dbPath); } catch { } + try { File.Delete(_dbPath + "-wal"); } catch { } + try { File.Delete(_dbPath + "-shm"); } catch { } + } + + private ClaudeDoDbContext NewContext() + { + var opts = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}") + .Options; + return new ClaudeDoDbContext(opts); + } + + private sealed class TestDbFactory : IDbContextFactory + { + private readonly Func _create; + public TestDbFactory(Func create) => _create = create; + public ClaudeDoDbContext CreateDbContext() => _create(); + } + + private sealed class DefaultStub : StubWorkerClient { } + + private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi + { + public Task> ListAsync(DateOnly day) => Task.FromResult(new List()); + public Task AddAsync(DateOnly day, string text) => Task.FromResult(null); + public Task UpdateAsync(string id, string text) => Task.CompletedTask; + public Task DeleteAsync(string id) => Task.CompletedTask; + } + + private sealed class NullServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + + private DetailsIslandViewModel NewDetailsVm(StubWorkerClient stub) + { + var factory = new TestDbFactory(NewContext); + return new DetailsIslandViewModel(factory, stub, new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator()); + } + + [Fact] + public void IsEmptyStateVisible_true_when_no_task_selected() + { + var vm = NewDetailsVm(new DefaultStub()); + + Assert.True(vm.IsEmptyStateVisible); + } + + [Fact] + public void IsEmptyStateVisible_false_once_a_task_is_bound() + { + var vm = NewDetailsVm(new DefaultStub()); + var row = new TaskRowViewModel { Id = "t1", Title = "Task" }; + + vm.Bind(row, "test"); + + Assert.False(vm.IsEmptyStateVisible); + } + + [Fact] + public void IsEmptyStateVisible_false_in_notes_mode_even_without_a_task() + { + var vm = NewDetailsVm(new DefaultStub()); + + vm.ShowNotes(); + + Assert.True(vm.IsNotesMode); + Assert.Null(vm.Task); + Assert.False(vm.IsEmptyStateVisible); + } + + [Fact] + public void IsEmptyStateVisible_false_in_prep_mode_even_without_a_task() + { + var vm = NewDetailsVm(new DefaultStub()); + + vm.ShowPrep(); + + Assert.True(vm.IsPrepMode); + Assert.Null(vm.Task); + Assert.False(vm.IsEmptyStateVisible); + } + + [Fact] + public void IsEmptyStateVisible_true_again_after_unbinding_a_task() + { + var vm = NewDetailsVm(new DefaultStub()); + var row = new TaskRowViewModel { Id = "t1", Title = "Task" }; + vm.Bind(row, "test"); + + vm.Bind(null, "test"); + + Assert.True(vm.IsEmptyStateVisible); + } +} diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandEmptyStateTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandEmptyStateTests.cs new file mode 100644 index 00000000..6ee7d48c --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandEmptyStateTests.cs @@ -0,0 +1,112 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.ViewModels.Islands; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +public class TasksIslandEmptyStateTests : IDisposable +{ + private readonly string _dbPath; + + public TasksIslandEmptyStateTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ui_emptystate_{Guid.NewGuid():N}.db"); + using var ctx = NewContext(); + ctx.Database.EnsureCreated(); + } + + public void Dispose() + { + try { File.Delete(_dbPath); } catch { } + try { File.Delete(_dbPath + "-wal"); } catch { } + try { File.Delete(_dbPath + "-shm"); } catch { } + } + + private ClaudeDoDbContext NewContext() + { + var opts = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}") + .Options; + return new ClaudeDoDbContext(opts); + } + + private sealed class TestDbFactory : IDbContextFactory + { + private readonly Func _create; + public TestDbFactory(Func create) => _create = create; + public ClaudeDoDbContext CreateDbContext() => _create(); + } + + private async Task SeedListAsync(string listId, string title = "seed task") + { + await using var db = NewContext(); + db.Lists.Add(new ListEntity { Id = listId, Name = "Work", CreatedAt = DateTime.UtcNow }); + db.Tasks.Add(new TaskEntity + { + Id = Guid.NewGuid().ToString("N"), + ListId = listId, + Title = title, + Status = TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + } + + private TasksIslandViewModel NewVm() => new(new TestDbFactory(NewContext), worker: null); + + [Fact] + public async Task IsTasksEmptyHintVisible_true_for_empty_user_list_with_working_dir() + { + await using var db = NewContext(); + db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); + await db.SaveChangesAsync(); + + var vm = NewVm(); + vm.LoadForList(new ListNavItemViewModel { Id = "user:L1", Name = "Work", Kind = ListKind.User, WorkingDir = "C:/repo" }); + await vm.LoadTask!; + + Assert.True(vm.IsTasksEmptyHintVisible); + Assert.False(vm.IsTasksEmptyRepoHintVisible); + } + + [Fact] + public async Task IsTasksEmptyRepoHintVisible_true_for_empty_user_list_without_working_dir() + { + await using var db = NewContext(); + db.Lists.Add(new ListEntity { Id = "L2", Name = "Work", CreatedAt = DateTime.UtcNow }); + await db.SaveChangesAsync(); + + var vm = NewVm(); + vm.LoadForList(new ListNavItemViewModel { Id = "user:L2", Name = "Work", Kind = ListKind.User, WorkingDir = null }); + await vm.LoadTask!; + + Assert.True(vm.IsTasksEmptyHintVisible); + Assert.True(vm.IsTasksEmptyRepoHintVisible); + } + + [Fact] + public async Task IsTasksEmptyRepoHintVisible_false_for_empty_smart_list_without_working_dir() + { + var vm = NewVm(); + vm.LoadForList(new ListNavItemViewModel { Id = "smart:important", Name = "Important", Kind = ListKind.Smart, WorkingDir = null }); + await vm.LoadTask!; + + Assert.True(vm.IsTasksEmptyHintVisible); + Assert.False(vm.IsTasksEmptyRepoHintVisible); + } + + [Fact] + public async Task IsTasksEmptyHintVisible_false_once_a_task_is_loaded() + { + await SeedListAsync("L3"); + + var vm = NewVm(); + vm.LoadForList(new ListNavItemViewModel { Id = "user:L3", Name = "Work", Kind = ListKind.User, WorkingDir = null }); + await vm.LoadTask!; + + Assert.False(vm.IsTasksEmptyHintVisible); + Assert.False(vm.IsTasksEmptyRepoHintVisible); + } +} From b997af442316b014e2323060d3779b2a8a38f6b3 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 21 Aug 2026 10:34:46 +0200 Subject: [PATCH 2/2] fix(ui): notify IsTasksEmptyRepoHintVisible on every list switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IsTasksEmptyRepoHintVisible reads _currentList?.Kind directly, but Kind isn't itself observed — the NotifyPropertyChangedFor chain only fires when IsLetClaudeVisible's value actually changes. Switching from a Smart list to an empty User list without a WorkingDir changes Kind while IsLetClaudeVisible (and Has*) stay false in both, so the hint never notified and stayed stale. --- .../Islands/TasksIslandViewModel.cs | 7 +++++ .../ViewModels/TasksIslandEmptyStateTests.cs | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index e1625721..a77150ed 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -387,6 +387,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable if (_currentList is not null) _currentList.PropertyChanged += OnCurrentListPropertyChanged; + // IsTasksEmptyRepoHintVisible reads _currentList?.Kind directly, but Kind isn't itself an + // observed property — the [NotifyPropertyChangedFor] chain only fires when IsLetClaudeVisible's + // *value* changes. Switching between two lists that both resolve IsLetClaudeVisible to the + // same bool (e.g. a Smart list -> an empty User list without a WorkingDir) changes Kind + // without ever notifying, leaving the hint stale. Force it explicitly on every list switch. + OnPropertyChanged(nameof(IsTasksEmptyRepoHintVisible)); + Items.Clear(); Rows.Clear(); HasOverdue = false; diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandEmptyStateTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandEmptyStateTests.cs index 6ee7d48c..e6023a2c 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandEmptyStateTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandEmptyStateTests.cs @@ -109,4 +109,31 @@ public class TasksIslandEmptyStateTests : IDisposable Assert.False(vm.IsTasksEmptyHintVisible); Assert.False(vm.IsTasksEmptyRepoHintVisible); } + + // Regression: switching from an empty Smart list (never eligible for the repo hint) to an + // empty User list without a WorkingDir changes the correct value of + // IsTasksEmptyRepoHintVisible from false to true, but IsLetClaudeVisible stays false in both + // cases (Smart lists are never User) and HasOverdue/HasOpen/HasCompleted stay false in both + // cases (both lists are empty) — so the [NotifyPropertyChangedFor] chain alone never fires. + [Fact] + public async Task IsTasksEmptyRepoHintVisible_notifies_on_switch_from_smart_list_to_empty_user_list_without_working_dir() + { + await using var db = NewContext(); + db.Lists.Add(new ListEntity { Id = "L4", Name = "Work", CreatedAt = DateTime.UtcNow }); + await db.SaveChangesAsync(); + + var vm = NewVm(); + vm.LoadForList(new ListNavItemViewModel { Id = "smart:important", Name = "Important", Kind = ListKind.Smart, WorkingDir = null }); + await vm.LoadTask!; + Assert.False(vm.IsTasksEmptyRepoHintVisible); + + var raisedProperties = new List(); + vm.PropertyChanged += (_, e) => raisedProperties.Add(e.PropertyName); + + vm.LoadForList(new ListNavItemViewModel { Id = "user:L4", Name = "Work", Kind = ListKind.User, WorkingDir = null }); + await vm.LoadTask!; + + Assert.Contains(nameof(TasksIslandViewModel.IsTasksEmptyRepoHintVisible), raisedProperties); + Assert.True(vm.IsTasksEmptyRepoHintVisible); + } }