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?", "feedbackPlaceholder": "Was soll der Agent korrigieren?",
"rerun": "Erneut ausführen", "rerun": "Erneut ausführen",
"refineTip": "Aufgabe mit Claude verfeinern", "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": { "lists": {
"heading": "Listen", "heading": "Listen",
@@ -254,7 +256,8 @@
"description": "Beschreibung", "description": "Beschreibung",
"steps": "Schritte", "steps": "Schritte",
"files": "Dateien" "files": "Dateien"
} },
"emptyState": "Wähle eine Aufgabe aus, um Details zu sehen"
}, },
"agent": { "agent": {
"openWorktreeTip": "Worktree im Datei-Explorer öffnen benötigt einen aktiven Worktree", "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?", "feedbackPlaceholder": "What should the agent fix?",
"rerun": "Re-run", "rerun": "Re-run",
"refineTip": "Refine this task with Claude", "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": { "lists": {
"heading": "Lists", "heading": "Lists",
@@ -254,7 +256,8 @@
"description": "Description", "description": "Description",
"steps": "Steps", "steps": "Steps",
"files": "Files" "files": "Files"
} },
"emptyState": "Select a task to see its details"
}, },
"agent": { "agent": {
"openWorktreeTip": "Open worktree in file explorer — requires an active worktree", "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; public bool IsTaskDetailVisible => !IsNotesMode && !IsPrepMode;
partial void OnIsNotesModeChanged(bool value) => OnPropertyChanged(nameof(IsTaskDetailVisible)); /// <summary>Centered placeholder shown when no task is selected — hidden in Notes/Prep mode,
partial void OnIsPrepModeChanged(bool value) => OnPropertyChanged(nameof(IsTaskDetailVisible)); /// 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!; public NotesEditorViewModel Notes { get; private set; } = null!;
@@ -55,6 +68,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
[NotifyCanExecuteChangedFor(nameof(DequeueCommand))] [NotifyCanExecuteChangedFor(nameof(DequeueCommand))]
[NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))] [NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))]
[NotifyPropertyChangedFor(nameof(TaskIdBadge))] [NotifyPropertyChangedFor(nameof(TaskIdBadge))]
[NotifyPropertyChangedFor(nameof(IsEmptyStateVisible))]
private TaskRowViewModel? _task; private TaskRowViewModel? _task;
// Editable fields // Editable fields
@@ -104,16 +104,38 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
[ObservableProperty] private string _statusPill = ""; [ObservableProperty] private string _statusPill = "";
[ObservableProperty] private bool _hasStatusPill; [ObservableProperty] private bool _hasStatusPill;
[ObservableProperty] private bool _isShowingCompleted = true; [ObservableProperty] private bool _isShowingCompleted = true;
[ObservableProperty] private bool _hasOverdue; [ObservableProperty]
[ObservableProperty] private bool _hasOpen; [NotifyPropertyChangedFor(nameof(IsTasksEmptyHintVisible))]
[ObservableProperty] private bool _hasCompleted; [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 bool _showOpenLabel;
[ObservableProperty] private string _completedHeader = ""; [ObservableProperty] private string _completedHeader = "";
[ObservableProperty] private bool _showNotesRow; [ObservableProperty] private bool _showNotesRow;
[ObservableProperty] private bool _isMyDayList; [ObservableProperty] private bool _isMyDayList;
[ObservableProperty] private bool _isLetClaudeVisible; [ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))]
private bool _isLetClaudeVisible;
[ObservableProperty] private bool _isQuickClaudeVisible; [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 // 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 // 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 // left standing to anchor an indicator to. The island header is the nearest surface still
@@ -146,6 +146,12 @@
DragCompleted="OnSplitterDragCompleted"/> DragCompleted="OnSplitterDragCompleted"/>
</Grid> </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 --> <!-- Notes mode -->
<Panel IsVisible="{Binding IsNotesMode}"> <Panel IsVisible="{Binding IsNotesMode}">
<islands:NotesEditorView DataContext="{Binding Notes}"/> <islands:NotesEditorView DataContext="{Binding Notes}"/>
@@ -97,60 +97,76 @@
Inset lives on the panel, NOT on the ListBox: Avalonia 12 leaves ScrollViewer.Padding 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 out of the Extent, so at max scroll the last row would sit below the viewport and stay
unreachable. --> unreachable. -->
<ListBox x:Name="RowsListBox" <Panel>
ItemsSource="{Binding Rows}" <ListBox x:Name="RowsListBox"
Background="Transparent" ItemsSource="{Binding Rows}"
BorderThickness="0"> Background="Transparent"
<ListBox.ItemsPanel> BorderThickness="0">
<ItemsPanelTemplate> <ListBox.ItemsPanel>
<VirtualizingStackPanel Margin="10,4"/> <ItemsPanelTemplate>
</ItemsPanelTemplate> <VirtualizingStackPanel Margin="10,4"/>
</ListBox.ItemsPanel> </ItemsPanelTemplate>
<ListBox.Styles> </ListBox.ItemsPanel>
<!-- SelectedItem is never bound — selection is handled entirely by the inner row Button's <ListBox.Styles>
SelectCommand, mirroring the pre-virtualization ItemsControl. Avalonia's ListBox has <!-- SelectedItem is never bound — selection is handled entirely by the inner row Button's
no "no selection" mode, so neutralize the app-wide selected/pointerover SelectCommand, mirroring the pre-virtualization ItemsControl. Avalonia's ListBox has
ContentPresenter overlay (App.axaml) instead of letting it double up with no "no selection" mode, so neutralize the app-wide selected/pointerover
TaskRowView's own hover/selected style. --> ContentPresenter overlay (App.axaml) instead of letting it double up with
<Style Selector="ListBoxItem"> TaskRowView's own hover/selected style. -->
<Setter Property="Padding" Value="0"/> <Style Selector="ListBoxItem">
<Setter Property="MinHeight" Value="0"/> <Setter Property="Padding" Value="0"/>
<Setter Property="CornerRadius" Value="0"/> <Setter Property="MinHeight" Value="0"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/> <Setter Property="CornerRadius" Value="0"/>
<Setter Property="Focusable" Value="{Binding Converter={x:Static uiconverters:NotHeaderRowConverter.Instance}}"/> <Setter Property="HorizontalContentAlignment" Value="Stretch"/>
</Style> <Setter Property="Focusable" Value="{Binding Converter={x:Static uiconverters:NotHeaderRowConverter.Instance}}"/>
<Style Selector="ListBoxItem:pointerover /template/ ContentPresenter"> </Style>
<Setter Property="Background" Value="Transparent"/> <Style Selector="ListBoxItem:pointerover /template/ ContentPresenter">
</Style> <Setter Property="Background" Value="Transparent"/>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter"> </Style>
<Setter Property="Background" Value="Transparent"/> <Style Selector="ListBoxItem:selected /template/ ContentPresenter">
</Style> <Setter Property="Background" Value="Transparent"/>
</ListBox.Styles> </Style>
<ListBox.DataTemplates> </ListBox.Styles>
<DataTemplate DataType="vm:HeaderRow"> <ListBox.DataTemplates>
<Grid ColumnDefinitions="*,Auto" Margin="14,14,14,6"> <DataTemplate DataType="vm:HeaderRow">
<TextBlock Grid.Column="0" Classes="eyebrow section-label" <Grid ColumnDefinitions="*,Auto" Margin="14,14,14,6">
Classes.overdue="{Binding IsOverdue}" <TextBlock Grid.Column="0" Classes="eyebrow section-label"
Text="{Binding Label}" VerticalAlignment="Center"/> Classes.overdue="{Binding IsOverdue}"
<Button Grid.Column="1" Classes="icon-btn" IsVisible="{Binding HasAction}" Text="{Binding Label}" VerticalAlignment="Center"/>
Command="{Binding ActionCommand}" <Button Grid.Column="1" Classes="icon-btn" IsVisible="{Binding HasAction}"
ToolTip.Tip="{loc:Tr tasks.clearCompletedTip}" Command="{Binding ActionCommand}"
VerticalAlignment="Center"> ToolTip.Tip="{loc:Tr tasks.clearCompletedTip}"
<PathIcon Data="{StaticResource Icon.Trash}" Width="13" Height="13" VerticalAlignment="Center">
Foreground="{DynamicResource BloodBrush}"/> <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> </Button>
</Grid> </DataTemplate>
</DataTemplate> </ListBox.DataTemplates>
<DataTemplate DataType="vm:TaskRowViewModel"> </ListBox>
<Button Classes="flat" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch" <!-- Empty state: shown below the add-task row once the list has zero visible tasks. -->
Command="{Binding $parent[ListBox].((vm:TasksIslandViewModel)DataContext).SelectCommand}" <StackPanel IsVisible="{Binding IsTasksEmptyHintVisible}"
CommandParameter="{Binding}"> HorizontalAlignment="Center" VerticalAlignment="Center"
<islands:TaskRowView/> MaxWidth="260" Spacing="6" IsHitTestVisible="False">
</Button> <TextBlock Text="{loc:Tr tasks.emptyHint}"
</DataTemplate> Foreground="{DynamicResource TextMuteBrush}"
</ListBox.DataTemplates> TextAlignment="Center" TextWrapping="Wrap"/>
</ListBox> <TextBlock IsVisible="{Binding IsTasksEmptyRepoHintVisible}"
Classes="meta"
Text="{loc:Tr tasks.emptyHintNoWorkingDir}"
Foreground="{DynamicResource TextMuteBrush}"
TextAlignment="Center" TextWrapping="Wrap"/>
</StackPanel>
</Panel>
</DockPanel> </DockPanel>
</UserControl> </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);
}
}