feat(claude-do): merge feat(ui): Empty States für die drei Islands (UX-Audit #4)
ClaudeDo-Task: 96503cc2-576c-4189-bd09-30c5e6aed5f0
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
/// <summary>Centered placeholder shown when no task is selected — hidden in Notes/Prep mode,
|
||||
/// where <see cref="IsTaskDetailVisible"/> is already false.</summary>
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public bool IsTasksEmptyHintVisible => !HasOverdue && !HasOpen && !HasCompleted;
|
||||
|
||||
/// <summary>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 <see cref="IsLetClaudeVisible"/> so Smart/Virtual lists — which also have
|
||||
/// no working dir but aren't missing a repo link — don't pick it up.</summary>
|
||||
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;
|
||||
|
||||
@@ -146,6 +146,12 @@
|
||||
DragCompleted="OnSplitterDragCompleted"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Empty state: no task selected, outside Notes/Prep mode -->
|
||||
<TextBlock IsVisible="{Binding IsEmptyStateVisible}"
|
||||
Text="{loc:Tr details.emptyState}"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
|
||||
<!-- Notes mode -->
|
||||
<Panel IsVisible="{Binding IsNotesMode}">
|
||||
<islands:NotesEditorView DataContext="{Binding Notes}"/>
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
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. -->
|
||||
<Panel>
|
||||
<ListBox x:Name="RowsListBox"
|
||||
ItemsSource="{Binding Rows}"
|
||||
Background="Transparent"
|
||||
@@ -152,5 +153,20 @@
|
||||
</ListBox.DataTemplates>
|
||||
</ListBox>
|
||||
|
||||
<!-- Empty state: shown below the add-task row once the list has zero visible tasks. -->
|
||||
<StackPanel IsVisible="{Binding IsTasksEmptyHintVisible}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
MaxWidth="260" Spacing="6" IsHitTestVisible="False">
|
||||
<TextBlock Text="{loc:Tr tasks.emptyHint}"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
TextAlignment="Center" TextWrapping="Wrap"/>
|
||||
<TextBlock IsVisible="{Binding IsTasksEmptyRepoHintVisible}"
|
||||
Classes="meta"
|
||||
Text="{loc:Tr tasks.emptyHintNoWorkingDir}"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
TextAlignment="Center" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
|
||||
@@ -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<ClaudeDoDbContext>()
|
||||
.UseSqlite($"Data Source={_dbPath}")
|
||||
.Options;
|
||||
return new ClaudeDoDbContext(opts);
|
||||
}
|
||||
|
||||
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
||||
{
|
||||
private readonly Func<ClaudeDoDbContext> _create;
|
||||
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
|
||||
public ClaudeDoDbContext CreateDbContext() => _create();
|
||||
}
|
||||
|
||||
private sealed class DefaultStub : StubWorkerClient { }
|
||||
|
||||
private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi
|
||||
{
|
||||
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>());
|
||||
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => Task.FromResult<DailyNoteDto?>(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);
|
||||
}
|
||||
}
|
||||
@@ -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<ClaudeDoDbContext>()
|
||||
.UseSqlite($"Data Source={_dbPath}")
|
||||
.Options;
|
||||
return new ClaudeDoDbContext(opts);
|
||||
}
|
||||
|
||||
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
||||
{
|
||||
private readonly Func<ClaudeDoDbContext> _create;
|
||||
public TestDbFactory(Func<ClaudeDoDbContext> 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<string?>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user