feat(ui): Empty States für Detail-Pane und Task-Liste (UX-Audit #4)

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.
This commit is contained in:
mika kuns
2026-08-21 10:27:02 +02:00
parent 36bb7a83b8
commit 9159f1b55c
8 changed files with 355 additions and 63 deletions
+5 -2
View File
@@ -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",
+5 -2
View File
@@ -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
@@ -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,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. -->
<ListBox x:Name="RowsListBox"
ItemsSource="{Binding Rows}"
Background="Transparent"
BorderThickness="0">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel Margin="10,4"/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.Styles>
<!-- SelectedItem is never bound — selection is handled entirely by the inner row Button's
SelectCommand, mirroring the pre-virtualization ItemsControl. Avalonia's ListBox has
no "no selection" mode, so neutralize the app-wide selected/pointerover
ContentPresenter overlay (App.axaml) instead of letting it double up with
TaskRowView's own hover/selected style. -->
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0"/>
<Setter Property="MinHeight" Value="0"/>
<Setter Property="CornerRadius" Value="0"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Focusable" Value="{Binding Converter={x:Static uiconverters:NotHeaderRowConverter.Instance}}"/>
</Style>
<Style Selector="ListBoxItem:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="Transparent"/>
</Style>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="Transparent"/>
</Style>
</ListBox.Styles>
<ListBox.DataTemplates>
<DataTemplate DataType="vm:HeaderRow">
<Grid ColumnDefinitions="*,Auto" Margin="14,14,14,6">
<TextBlock Grid.Column="0" Classes="eyebrow section-label"
Classes.overdue="{Binding IsOverdue}"
Text="{Binding Label}" VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="icon-btn" IsVisible="{Binding HasAction}"
Command="{Binding ActionCommand}"
ToolTip.Tip="{loc:Tr tasks.clearCompletedTip}"
VerticalAlignment="Center">
<PathIcon Data="{StaticResource Icon.Trash}" Width="13" Height="13"
Foreground="{DynamicResource BloodBrush}"/>
<Panel>
<ListBox x:Name="RowsListBox"
ItemsSource="{Binding Rows}"
Background="Transparent"
BorderThickness="0">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel Margin="10,4"/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.Styles>
<!-- SelectedItem is never bound — selection is handled entirely by the inner row Button's
SelectCommand, mirroring the pre-virtualization ItemsControl. Avalonia's ListBox has
no "no selection" mode, so neutralize the app-wide selected/pointerover
ContentPresenter overlay (App.axaml) instead of letting it double up with
TaskRowView's own hover/selected style. -->
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0"/>
<Setter Property="MinHeight" Value="0"/>
<Setter Property="CornerRadius" Value="0"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Focusable" Value="{Binding Converter={x:Static uiconverters:NotHeaderRowConverter.Instance}}"/>
</Style>
<Style Selector="ListBoxItem:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="Transparent"/>
</Style>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="Transparent"/>
</Style>
</ListBox.Styles>
<ListBox.DataTemplates>
<DataTemplate DataType="vm:HeaderRow">
<Grid ColumnDefinitions="*,Auto" Margin="14,14,14,6">
<TextBlock Grid.Column="0" Classes="eyebrow section-label"
Classes.overdue="{Binding IsOverdue}"
Text="{Binding Label}" VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="icon-btn" IsVisible="{Binding HasAction}"
Command="{Binding ActionCommand}"
ToolTip.Tip="{loc:Tr tasks.clearCompletedTip}"
VerticalAlignment="Center">
<PathIcon Data="{StaticResource Icon.Trash}" Width="13" Height="13"
Foreground="{DynamicResource BloodBrush}"/>
</Button>
</Grid>
</DataTemplate>
<DataTemplate DataType="vm:TaskRowViewModel">
<Button Classes="flat" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ListBox].((vm:TasksIslandViewModel)DataContext).SelectCommand}"
CommandParameter="{Binding}">
<islands:TaskRowView/>
</Button>
</Grid>
</DataTemplate>
<DataTemplate DataType="vm:TaskRowViewModel">
<Button Classes="flat" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ListBox].((vm:TasksIslandViewModel)DataContext).SelectCommand}"
CommandParameter="{Binding}">
<islands:TaskRowView/>
</Button>
</DataTemplate>
</ListBox.DataTemplates>
</ListBox>
</DataTemplate>
</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,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<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);
}
}