fix(ui): duplizierter Confirm-Dialog mit "Delete"-Knopf – zusammenfuehren und lokalisieren

F4: DetailsIslandView/TasksIslandView hatten je eine view-lokale Confirm/Error-Dialog-Kopie,
teils mit hartkodiertem "Delete"-Knopf fuer jede Aktion (inkl. der Stash+Merge-Bestaetigung,
die nichts loescht). Beide Kopien entfernt; DetailsIslandViewModel/TasksIslandViewModel routen
Confirm/Error jetzt durch den geteilten Dialogs-Seam (IDialogService, propagiert vom Shell wie
schon bei Lists/Tasks) statt ueber eine vom Code-behind gesetzte Func-Property.

IDialogService.ConfirmAsync bekommt einen optionalen confirmLabel-Parameter (Default: lokalisiertes
"Confirm"); nur die echten Delete-Aktionen (Task loeschen, abgeschlossene Tasks leeren) uebergeben
das lokalisierte "Delete"-Label.

F5: "Cancel"/"Confirm"/"Delete"/"OK" und die Titel "Confirm"/"Error" durch Loc.T mit neuen Keys
unter "dialog.*" ersetzt (en.json + de.json). Mission Controls eigene Dialog-Kopie bleibt bestehen
(eigenes Fenster, WindowDialogService ist an den Haupt-Owner gebunden) — nur ihre Labels wurden
lokalisiert.

Out of scope (siehe Task-Beschreibung): die Nachrichtentexte der Dialoge bleiben unlokalisiert.
This commit is contained in:
Mika Kuns
2026-09-16 15:43:38 +02:00
committed by mika kuns
parent 684b0115e5
commit 86cc309ba9
17 changed files with 126 additions and 199 deletions
@@ -805,6 +805,14 @@
"done": "Fertig"
}
},
"dialog": {
"cancel": "Abbrechen",
"confirm": "Bestätigen",
"delete": "Löschen",
"ok": "OK",
"confirmTitle": "Bestätigen",
"errorTitle": "Fehler"
},
"shell": {
"menu": {
"help": "Hilfe",
@@ -805,6 +805,14 @@
"done": "Done"
}
},
"dialog": {
"cancel": "Cancel",
"confirm": "Confirm",
"delete": "Delete",
"ok": "OK",
"confirmTitle": "Confirm",
"errorTitle": "Error"
},
"shell": {
"menu": {
"help": "Help",
+3 -2
View File
@@ -28,8 +28,9 @@ public interface IDialogService
Task ShowConflictResolverAsync(ConflictResolverViewModel vm);
Task ShowLogVisualizerAsync(LogVisualizerViewModel vm);
/// <summary>Modal yes/no confirmation. Returns true only when confirmed.</summary>
Task<bool> ConfirmAsync(string message);
/// <summary>Modal yes/no confirmation. Returns true only when confirmed. <paramref name="confirmLabel"/>
/// overrides the default localized "Confirm" button text (e.g. a localized "Delete" for destructive actions).</summary>
Task<bool> ConfirmAsync(string message, string? confirmLabel = null);
/// <summary>Modal error notice with a single dismiss button.</summary>
Task ShowErrorAsync(string message);
@@ -291,11 +291,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
// Set by shell so DeleteTaskCommand can remove from list
public Func<TaskRowViewModel, System.Threading.Tasks.Task>? DeleteFromList { get; set; }
// Set by the view so DeleteTaskCommand can prompt yes/no before deleting
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
// Set by the view so DeleteTaskCommand can show an error message
public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }
// Set by the shell (mirrors ListsIslandViewModel.Dialogs) so confirm/error prompts go
// through the one shared dialog implementation instead of a view-local copy.
public IDialogService? Dialogs { get; set; }
// ── Review ──────────────────────────────────────────────────────────────
[ObservableProperty]
@@ -1042,9 +1040,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
{
if (Task == null) return;
var row = Task;
if (ConfirmAsync != null)
if (Dialogs != null)
{
var ok = await ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.");
var ok = await Dialogs.ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.", Loc.T("dialog.delete"));
if (!ok) return;
}
// Routed through the worker (mirrors the MCP delete_task tool) so a deleted child
@@ -1060,14 +1058,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
{
// Belt and braces: the connection can drop between the CanExecute check and
// this call, so a stray throw here must surface, not vanish silently.
if (ShowErrorAsync != null)
await ShowErrorAsync(ex.Message);
if (Dialogs != null)
await Dialogs.ShowErrorAsync(ex.Message);
return;
}
if (!deleted)
{
if (ShowErrorAsync != null)
await ShowErrorAsync(error ?? "Delete failed.");
if (Dialogs != null)
await Dialogs.ShowErrorAsync(error ?? "Delete failed.");
return;
}
if (DeleteFromList != null)
@@ -1216,10 +1214,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private async System.Threading.Tasks.Task ResetAndRetryAsync()
{
if (Task == null) return;
if (ConfirmAsync == null) return;
if (Dialogs == null) return;
var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
var ok = await ConfirmAsync(
var ok = await Dialogs.ConfirmAsync(
$"Reset and retry?\nThis discards branch {branchName} (and uncommitted changes), then queues the task to run from the beginning.");
if (!ok) return;
@@ -1279,31 +1277,31 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
// Nothing landed -- the target working dir has uncommitted changes (often another
// session's WIP in the shared main checkout). Offer the one-shot stash retry; git
// restores the changes itself once the merge is through.
if (result?.Status == "dirty_target" && ConfirmAsync != null
&& await ConfirmAsync(Loc.T("vm.detailsIsland.stashAndMergeConfirm")))
if (result?.Status == "dirty_target" && Dialogs != null
&& await Dialogs.ConfirmAsync(Loc.T("vm.detailsIsland.stashAndMergeConfirm")))
{
ApproveOp.Report(Loc.T("ops.merge.merging"));
result = await _worker.ApproveReviewAsync(
taskId, Merge.SelectedMergeTarget ?? "", autostash: true);
}
if (result?.Status == "dirty_target" && ShowErrorAsync != null)
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.dirtyTarget"));
if (result?.Status == "dirty_target" && Dialogs != null)
await Dialogs.ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.dirtyTarget"));
else if (!hasChildren && result?.Status == "conflict")
await _merge.ResolveConflictAsync(taskId, Merge.SelectedMergeTarget ?? "");
// The merge itself already landed; the verify command failed, so the task stayed
// out of Done. Surface that instead of silently looking like nothing happened.
else if (!hasChildren && result?.Status == "verify_failed" && ShowErrorAsync != null)
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.verifyFailed"));
else if (!hasChildren && result?.Status == "verify_failed" && Dialogs != null)
await Dialogs.ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.verifyFailed"));
// Nothing landed -- the merge was refused before touching the repo because the
// branch would have overwritten an untracked file in the target working directory.
else if (!hasChildren && result?.Status == "untracked_collision" && ShowErrorAsync != null)
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.untrackedCollision"));
else if (!hasChildren && result?.Status == "untracked_collision" && Dialogs != null)
await Dialogs.ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.untrackedCollision"));
}
catch (Exception ex)
{
if (ShowErrorAsync != null)
await ShowErrorAsync(ex.Message);
if (Dialogs != null)
await Dialogs.ShowErrorAsync(ex.Message);
}
finally
{
@@ -1348,7 +1346,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
try { await _worker.SubmitTaskForReviewAsync(Task.Id); }
catch (Exception ex)
{
if (ShowErrorAsync != null) await ShowErrorAsync(ex.Message);
if (Dialogs != null) await Dialogs.ShowErrorAsync(ex.Message);
}
}
@@ -1379,9 +1377,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
[RelayCommand]
private async System.Threading.Tasks.Task ResetReviewAsync()
{
if (Task is null || !_worker.IsConnected || ConfirmAsync is null) return;
if (Task is null || !_worker.IsConnected || Dialogs is null) return;
var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
var ok = await ConfirmAsync(
var ok = await Dialogs.ConfirmAsync(
$"Reset working tree?\nThis discards branch {branchName} (and all changes) and returns the task to Idle.");
if (!ok) return;
try { await _worker.ResetTaskAsync(Task.Id); }
@@ -1209,9 +1209,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// block removing a completed planning parent together with its done children.
var toDelete = completed.OrderByDescending(r => r.IsChild).ToList();
if (ConfirmAsync is not null)
if (Dialogs is not null)
{
var ok = await ConfirmAsync($"Clear {toDelete.Count} completed task(s)? This cannot be undone.");
var ok = await Dialogs.ConfirmAsync($"Clear {toDelete.Count} completed task(s)? This cannot be undone.", Loc.T("dialog.delete"));
if (!ok) return;
}
@@ -1339,9 +1339,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
private async Task DeleteTaskAsync(TaskRowViewModel? row)
{
if (row is null || row.HasInteractiveSession || _worker is null) return;
if (ConfirmAsync is not null)
if (Dialogs is not null)
{
var ok = await ConfirmAsync(Loc.T("vm.tasksIsland.deleteTaskConfirm", row.Title));
var ok = await Dialogs.ConfirmAsync(Loc.T("vm.tasksIsland.deleteTaskConfirm", row.Title), Loc.T("dialog.delete"));
if (!ok) return;
}
@@ -1653,8 +1653,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
if (outcome.Result == DiscardPlanningResult.BlockedByQueuedChildren)
{
if (ConfirmAsync is null) return;
var ok = await ConfirmAsync(
if (Dialogs is null) return;
var ok = await Dialogs.ConfirmAsync(
$"{outcome.QueuedChildrenCount} child task(s) are queued.\n" +
"Dequeue them and discard the planning session?");
if (!ok) return;
@@ -1663,18 +1663,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
}
else if (outcome.Result == DiscardPlanningResult.BlockedByRunningChildren)
{
if (ConfirmAsync is null) return;
await ConfirmAsync(
if (Dialogs is null) return;
await Dialogs.ConfirmAsync(
$"{outcome.RunningChildrenCount} child task(s) are still running.\n" +
"Cancel them first, then try again.");
}
}
/// <summary>
/// Wired by the view via <see cref="ShowConfirmAsync"/>. Returns true when the user confirms.
/// </summary>
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
private bool CanRunPlanningOperation() => !PlanningOperation.IsRunning;
[RelayCommand(CanExecute = nameof(CanRunPlanningOperation))]
@@ -68,7 +68,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
// Set by MainWindow so a reveal can bring the main window to the foreground.
public Action? BringToFront { get; set; }
// Single dialog seam (set by MainWindow); propagated to the lists island.
// Single dialog seam (set by MainWindow); propagated to the islands.
private IDialogService? _dialogs;
public IDialogService? Dialogs
{
@@ -78,6 +78,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
_dialogs = value;
if (Lists is not null) Lists.Dialogs = value;
if (Tasks is not null) Tasks.Dialogs = value;
if (Details is not null) Details.Dialogs = value;
}
}
@@ -173,8 +173,6 @@ public partial class DetailsIslandView : UserControl
await modal.ShowDialog(owner);
};
vm.ConfirmAsync = ShowConfirmAsync;
vm.ShowErrorAsync = ShowErrorDialogAsync;
}
}
@@ -213,87 +211,4 @@ public partial class DetailsIslandView : UserControl
_descriptionHeights[id] = DetailBodyGrid.RowDefinitions[0].Height.Value;
}
private async System.Threading.Tasks.Task ShowErrorDialogAsync(string message)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return;
var ok = new Button { Content = "OK", MinWidth = 90 };
var dialog = new Window
{
Title = "Error",
Width = 360,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
ShowInTaskbar = false,
Background = this.FindResource("SurfaceBrush") as IBrush,
Content = new StackPanel
{
Spacing = 16,
Margin = new Thickness(20),
Children =
{
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
HorizontalAlignment = HorizontalAlignment.Right,
Children = { ok }
}
}
}
};
ok.Click += (_, _) => dialog.Close();
await dialog.ShowDialog(owner);
}
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return false;
var tcs = new TaskCompletionSource<bool>();
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
var confirm = new Button { Content = "Delete", MinWidth = 90, Classes = { "danger" } };
var dialog = new Window
{
Title = "Confirm",
Width = 360,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
ShowInTaskbar = false,
Background = this.FindResource("SurfaceBrush") as IBrush,
Content = new StackPanel
{
Spacing = 16,
Margin = new Thickness(20),
Children =
{
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
HorizontalAlignment = HorizontalAlignment.Right,
Children = { cancel, confirm }
}
}
}
};
cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
dialog.Closed += (_, _) => tcs.TrySetResult(false);
_ = dialog.ShowDialog(owner);
return await tcs.Task;
}
}
@@ -55,7 +55,6 @@ public partial class TasksIslandView : UserControl
await modal.ShowDialog(owner);
// ShowDialog completes once the window is closed (CloseAction or OS close).
};
vm.ConfirmAsync = ShowConfirmAsync;
vm.SelectionChanged += (_, _) => ScrollSelectedIntoView();
}
};
@@ -71,50 +70,6 @@ public partial class TasksIslandView : UserControl
Dispatcher.UIThread.Post(() => RowsListBox.ScrollIntoView(target), DispatcherPriority.Background);
}
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return false;
var tcs = new TaskCompletionSource<bool>();
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
var dialog = new Window
{
Title = "Confirm",
Width = 380,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
ShowInTaskbar = false,
Background = this.FindResource("SurfaceBrush") as IBrush,
Content = new StackPanel
{
Margin = new Thickness(20),
Spacing = 16,
Children =
{
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 8,
Children = { cancel, confirm },
},
},
},
};
cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
dialog.Closed += (_, _) => tcs.TrySetResult(false);
_ = dialog.ShowDialog(owner);
return await tcs.Task;
}
// ── Custom ghost drag ────────────────────────────────────────────────────
// Replaces both the OLE DoDragDropAsync reorder and the OLE drop-to-queue path: a hand-built
// drag (pointer capture + a transparent topmost ghost window) is the only way to get a
@@ -8,6 +8,7 @@ using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Avalonia.VisualTree;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.ViewModels;
namespace ClaudeDo.Ui.Views.MissionControl;
@@ -73,20 +74,21 @@ public partial class MissionControlView : UserControl
UpdateQueueColumn();
}
// Abandon-run confirmation -- same TaskCompletionSource-backed plain Window as
// TasksIslandView.ShowConfirmAsync (no shared control for this shape yet).
// Abandon-run confirmation -- own plain-Window TaskCompletionSource dialog, not routed
// through WindowDialogService: it's owned by the main window, so showing it while Mission
// Control (a separate Window) is focused would put it behind Mission Control.
private async Task<bool> ShowConfirmAsync(string message)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return false;
var tcs = new TaskCompletionSource<bool>();
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
var cancel = new Button { Content = Loc.T("dialog.cancel"), MinWidth = 90 };
var confirm = new Button { Content = Loc.T("dialog.confirm"), MinWidth = 90, Classes = { "danger" } };
var dialog = new Window
{
Title = "Confirm",
Title = Loc.T("dialog.confirmTitle"),
Width = 380,
SizeToContent = SizeToContent.Height,
CanResize = false,
+10 -9
View File
@@ -6,6 +6,7 @@ using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Layout;
using Avalonia.Media;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Conflicts;
@@ -71,7 +72,7 @@ public sealed class WindowDialogService : IDialogService
public async Task ShowSettingsAsync(SettingsModalViewModel vm)
{
vm.Worktrees.ConfirmAsync = ConfirmAsync;
vm.Worktrees.ConfirmAsync = message => ConfirmAsync(message);
var dlg = new SettingsModalView { DataContext = vm };
vm.RequestRepoImport = () =>
{
@@ -90,7 +91,7 @@ public sealed class WindowDialogService : IDialogService
{
var dlg = new ListSettingsModalView { DataContext = vm };
vm.CloseAction = () => dlg.Close();
vm.ConfirmAsync = ConfirmAsync;
vm.ConfirmAsync = message => ConfirmAsync(message);
vm.ShowErrorAsync = ShowErrorAsync;
await dlg.ShowDialog(_owner);
}
@@ -144,7 +145,7 @@ public sealed class WindowDialogService : IDialogService
_diffViewerWindow = diffDlg;
diffDlg.Show(_owner);
};
vm.ConfirmAction = ConfirmAsync;
vm.ConfirmAction = message => ConfirmAsync(message);
if (Shell is { } shell)
{
vm.ResolveMergeVm = shell.ResolveMergeVm;
@@ -206,13 +207,13 @@ public sealed class WindowDialogService : IDialogService
_cheatSheets.Activate();
}
public Task<bool> ConfirmAsync(string message)
public Task<bool> ConfirmAsync(string message, string? confirmLabel = null)
{
var tcs = new TaskCompletionSource<bool>();
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
var cancel = new Button { Content = Loc.T("dialog.cancel"), MinWidth = 90 };
var confirm = new Button { Content = confirmLabel ?? Loc.T("dialog.confirm"), MinWidth = 90, Classes = { "danger" } };
var dialog = NoticeWindow("Confirm", 380, message,
var dialog = NoticeWindow(Loc.T("dialog.confirmTitle"), 380, message,
new StackPanel
{
Orientation = Orientation.Horizontal,
@@ -231,8 +232,8 @@ public sealed class WindowDialogService : IDialogService
public Task ShowErrorAsync(string message)
{
var ok = new Button { Content = "OK", MinWidth = 90 };
var dialog = NoticeWindow("Error", 360, message,
var ok = new Button { Content = Loc.T("dialog.ok"), MinWidth = 90 };
var dialog = NoticeWindow(Loc.T("dialog.errorTitle"), 360, message,
new StackPanel
{
Orientation = Orientation.Horizontal,