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..a77150ed 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
@@ -365,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/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..e6023a2c
--- /dev/null
+++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandEmptyStateTests.cs
@@ -0,0 +1,139 @@
+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);
+ }
+
+ // 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);
+ }
+}