fix(ui): Aktions- und Bestätigungs-Konsistenz (UX-Audit #3)

Sechs Konsistenz-Fixes: Delete-Task ins Zeilen-Kontextmenü (routet über den
Worker wie DetailsIslandViewModel.DeleteTaskAsync, damit ein gelöschtes Child
den WaitingForChildren-Parent korrekt weiterschaltet); EnqueueCommand als
"Send to queue"-Button im Detail-Pane verdrahtet; Settings-Eintrag im
Listen-Kontextmenü ergänzt; Worktree-Discard und Reset-All laufen jetzt über
das jeweils vorhandene Confirm-Hook (ConfirmAction / neuer ConfirmAsync-Hook
auf WorktreesSettingsTabViewModel) statt ohne Rückfrage bzw. über ein
Inline-Reveal-Banner; killSessionTip/closeSession und die deutschen
Queue-Strings im usageMonitor vereinheitlicht.

Kein zweiter Dialog-Mechanismus eingeführt — überall die vorhandenen Hooks
wiederverwendet.
This commit is contained in:
mika kuns
2026-08-21 09:51:51 +02:00
parent cc60cb4f0a
commit 2935eef75a
12 changed files with 144 additions and 32 deletions
@@ -1146,6 +1146,42 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
"vm.queue.baseDirtyWarning", warning.ModifiedCount, warning.UntrackedCount));
}
// Row-level delete for the task context menu. Routed through the worker (mirrors
// DetailsIslandViewModel.DeleteTaskAsync) so a deleted child correctly advances a
// WaitingForChildren parent instead of bypassing TaskStateService.
[RelayCommand]
private async Task DeleteTaskAsync(TaskRowViewModel? row)
{
if (row is null || _worker is null) return;
if (ConfirmAsync is not null)
{
var ok = await ConfirmAsync(Loc.T("vm.tasksIsland.deleteTaskConfirm", row.Title));
if (!ok) return;
}
bool deleted;
string? error;
try
{
(deleted, error) = await _worker.DeleteTaskAsync(row.Id);
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.deleteTaskFailed", ex.Message));
return;
}
if (!deleted)
{
ErrorReported?.Invoke(error ?? Loc.T("vm.tasksIsland.deleteTaskFailed", "unknown error"));
return;
}
Items.Remove(row);
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
private async Task SendToQueueAsync(TaskRowViewModel? row)
{
@@ -15,12 +15,15 @@ public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
[ObservableProperty] private bool _worktreeAutoCleanupEnabled;
[ObservableProperty] private int _worktreeAutoCleanupDays = 7;
[ObservableProperty] private bool _showResetConfirm;
[ObservableProperty] private string _statusMessage = "";
[ObservableProperty] private bool _isBusy;
public OperationStatus ResetStatus { get; } = new();
/// <summary>Wired by <c>WindowDialogService.ShowSettingsAsync</c>. A missing hook (null)
/// must not reset silently — <see cref="ConfirmResetAll"/> aborts instead.</summary>
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
public IReadOnlyList<string> WorktreeStrategies { get; } = new[] { "sibling", "central" };
public WorktreesSettingsTabViewModel(IWorkerClient worker) => _worker = worker;
@@ -52,13 +55,13 @@ public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
finally { IsBusy = false; }
}
[RelayCommand] private void RequestResetConfirm() => ShowResetConfirm = true;
[RelayCommand] private void CancelResetConfirm() => ShowResetConfirm = false;
[RelayCommand]
private async Task ConfirmResetAll()
{
ShowResetConfirm = false; StatusMessage = "";
if (ConfirmAsync is null) return;
if (!await ConfirmAsync(Loc.T("settings.worktrees.confirmRemoveAll"))) return;
StatusMessage = "";
using var op = ResetStatus.Begin(Loc.T("ops.worktrees.resetting"));
try
{
@@ -290,6 +290,8 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
private async Task Discard(WorktreeOverviewRowViewModel? row)
{
if (row is null || row.State != WorktreeState.Active) return;
if (ConfirmAction is not null && !await ConfirmAction(Loc.T("vm.worktreesOverview.discardConfirm", row.TaskTitle))) return;
var (ok, err) = await _worker.SetWorktreeStateAsync(row.TaskId, WorktreeState.Discarded);
if (ok) row.State = WorktreeState.Discarded;
else StatusMessage = err ?? Loc.T("vm.worktreesOverview.discardFailed");
@@ -414,6 +414,17 @@
Command="{Binding ResetReviewCommand}" />
</StackPanel>
<!-- Send an Idle task to the queue for Claude to pick up on the next run. -->
<StackPanel Spacing="8">
<Border Height="1" Background="{DynamicResource LineBrush}" />
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}">
<Button Classes="btn" Content="{loc:Tr details.sendToQueue}"
ToolTip.Tip="{loc:Tr details.sendToQueueTip}"
ToolTip.ShowOnDisabled="True"
Command="{Binding EnqueueCommand}" />
</StackPanel>
</StackPanel>
<!-- Submit an interactive (ConPTY) session for review: an Idle/Failed task that
still has a worktree. Commits the worktree, then moves it to WaitingForReview. -->
<StackPanel Spacing="8" IsVisible="{Binding CanSubmitForReview}">
@@ -139,6 +139,10 @@
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInTerminalCommand}"
CommandParameter="{Binding}"/>
<Separator/>
<MenuItem Header="{loc:Tr lists.contextSettings}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
CommandParameter="{Binding}"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="20,*,Auto">
@@ -67,6 +67,8 @@ public partial class TaskRowView : UserControl
menu.Items.Add(MakeItem("tasks.ctxClearSchedule", OnClearScheduleClick, row.HasSchedule));
menu.Items.Add(MakeItem("tasks.ctxAddToMyDay", OnAddToMyDayClick, row.CanAddToMyDay));
menu.Items.Add(MakeItem("tasks.ctxRemoveFromMyDay", OnRemoveFromMyDayClick, row.IsMyDay));
menu.Items.Add(new Separator());
menu.Items.Add(MakeItem("tasks.ctxDeleteTask", OnDeleteTaskClick));
menu.Open(border);
e.Handled = true;
@@ -114,6 +116,12 @@ public partial class TaskRowView : UserControl
await vm.RemoveFromMyDayCommand.ExecuteAsync(row);
}
private async void OnDeleteTaskClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.DeleteTaskCommand.ExecuteAsync(row);
}
private void OnOpenPlanningSessionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
@@ -244,25 +244,12 @@
<Button Classes="btn" Content="{loc:Tr settings.worktrees.cleanupFinished}"
Command="{Binding Worktrees.CleanupWorktreesCommand}"
HorizontalAlignment="Left"/>
<StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="{loc:Tr settings.worktrees.forceRemoveAll}" Classes="danger"
Command="{Binding Worktrees.RequestResetConfirmCommand}"
Command="{Binding Worktrees.ConfirmResetAllCommand}"
HorizontalAlignment="Left"
IsVisible="{Binding !Worktrees.ShowResetConfirm}"/>
<Border Classes="danger-box"
IsVisible="{Binding Worktrees.ShowResetConfirm}">
<StackPanel Spacing="8">
<TextBlock Text="{loc:Tr settings.worktrees.confirmRemoveAll}"
Foreground="{DynamicResource TextBrush}" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="btn" Content="{loc:Tr settings.cancel}" Command="{Binding Worktrees.CancelResetConfirmCommand}"/>
<Button Content="{loc:Tr settings.worktrees.removeAll}" Classes="danger"
Command="{Binding Worktrees.ConfirmResetAllCommand}"
IsEnabled="{Binding !Worktrees.ResetStatus.IsRunning}"/>
<ctl:OperationIndicator Status="{Binding Worktrees.ResetStatus}"/>
</StackPanel>
</StackPanel>
</Border>
IsEnabled="{Binding !Worktrees.ResetStatus.IsRunning}"/>
<ctl:OperationIndicator Status="{Binding Worktrees.ResetStatus}"/>
</StackPanel>
</StackPanel>
<TextBlock Classes="meta" Text="{Binding Worktrees.StatusMessage}"
@@ -70,6 +70,7 @@ public sealed class WindowDialogService : IDialogService
public async Task ShowSettingsAsync(SettingsModalViewModel vm)
{
vm.Worktrees.ConfirmAsync = ConfirmAsync;
var dlg = new SettingsModalView { DataContext = vm };
await dlg.ShowDialog(ActiveOwner());
}