From 80fe188a484b67f98e33c60e387d01ce99bbc594 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 2 Sep 2026 10:00:21 +0200 Subject: [PATCH] feat(ui): add "move to list" to the task row context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuses the existing drag-drop move (MoveTaskToListAsync) so the same running/worktree guards apply — only ListId changes. Also count WaitingForReview tasks (not Done-with-a-branch) for the review portion of the status pill, and localize the pill and open-task count strings that were still hardcoded English. --- src/ClaudeDo.Localization/locales/de.json | 3 ++- src/ClaudeDo.Localization/locales/en.json | 3 ++- .../Islands/TasksIslandViewModel.cs | 8 +++--- .../Views/Islands/TaskRowView.axaml.cs | 25 +++++++++++++++++++ 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index d125bed2..4ca377d4 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -208,6 +208,7 @@ "ctxRefineTask": "Aufgabe verfeinern", "ctxPlanning": "Planung", "ctxSchedule": "Zeitplan", + "ctxMoveToList": "In Liste verschieben", "ctxDeleteTask": "Aufgabe löschen", "reasonAlreadyRunning": "Aufgabe läuft bereits", "reasonAlreadyQueued": "Bereits in der Warteschlange", @@ -736,7 +737,7 @@ "planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" }, "taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" }, "queue": { "baseDirtyWarning": "Achtung: Das Repo dieser Liste hat uncommittete Änderungen ({0} geändert, {1} untracked) — ein neuer Worktree startet vom letzten Commit und enthält sie nicht." }, - "tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}", "setStatusFailed": "Status konnte nicht aktualisiert werden: {0}", "cancelFailed": "Abbrechen fehlgeschlagen: {0}", "rejectToQueueFailed": "Zurückweisen in die Warteschlange fehlgeschlagen: {0}", "rejectToIdleFailed": "Zurückweisen fehlgeschlagen: {0}", "deleteTaskConfirm": "\"{0}\" löschen? Dies kann nicht rückgängig gemacht werden.", "deleteTaskFailed": "Löschen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}", "removeFromQueueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" }, + "tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "statusPill": "{0} laufend · {1} Prüfung", "openTaskOne": "1 offene Aufgabe", "openTasks": "{0} offene Aufgaben", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}", "setStatusFailed": "Status konnte nicht aktualisiert werden: {0}", "cancelFailed": "Abbrechen fehlgeschlagen: {0}", "rejectToQueueFailed": "Zurückweisen in die Warteschlange fehlgeschlagen: {0}", "rejectToIdleFailed": "Zurückweisen fehlgeschlagen: {0}", "deleteTaskConfirm": "\"{0}\" löschen? Dies kann nicht rückgängig gemacht werden.", "deleteTaskFailed": "Löschen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}", "removeFromQueueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" }, "diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." }, "planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" }, "merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" }, diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 1ec8540e..e58e4780 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -208,6 +208,7 @@ "ctxRefineTask": "Refine task", "ctxPlanning": "Planning", "ctxSchedule": "Schedule", + "ctxMoveToList": "Move to list", "ctxDeleteTask": "Delete task", "reasonAlreadyRunning": "Task is already running", "reasonAlreadyQueued": "Already in the queue", @@ -736,7 +737,7 @@ "planningBadge": { "active": "PLANNING", "finalized": "PLANNED" }, "taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" }, "queue": { "baseDirtyWarning": "Heads up: this list's repo has uncommitted changes ({0} modified, {1} untracked) — a new worktree starts from the last commit and won't include them." }, - "tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}", "setStatusFailed": "Failed to update status: {0}", "cancelFailed": "Cancel failed: {0}", "rejectToQueueFailed": "Reject to queue failed: {0}", "rejectToIdleFailed": "Reject failed: {0}", "deleteTaskConfirm": "Delete \"{0}\"? This cannot be undone.", "deleteTaskFailed": "Delete failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}", "removeFromQueueFailed": "Remove from queue failed: {0}", "openWorktreeFailed": "Couldn't open worktree: {0}" }, + "tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "statusPill": "{0} running · {1} review", "openTaskOne": "1 open task", "openTasks": "{0} open tasks", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}", "setStatusFailed": "Failed to update status: {0}", "cancelFailed": "Cancel failed: {0}", "rejectToQueueFailed": "Reject to queue failed: {0}", "rejectToIdleFailed": "Reject failed: {0}", "deleteTaskConfirm": "Delete \"{0}\"? This cannot be undone.", "deleteTaskFailed": "Delete failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}", "removeFromQueueFailed": "Remove from queue failed: {0}", "openWorktreeFailed": "Couldn't open worktree: {0}" }, "diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." }, "planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" }, "merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" }, diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index c95079ba..1f777c46 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -806,14 +806,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable var now = DateTime.Now; var open = Items.Count(i => !i.Done); var running = Items.Count(i => i.Status == TaskStatus.Running); - var review = Items.Count(i => i.Status == TaskStatus.Done && i.Branch != null); + var review = Items.Count(i => i.Status == TaskStatus.WaitingForReview); // The notes list holds no tasks — an "0 open tasks" line under its header is just noise. - Subtitle = IsNotesList ? "" : open == 1 ? "1 open task" : $"{open} open tasks"; + Subtitle = IsNotesList ? "" + : open == 1 ? Loc.T("vm.tasksIsland.openTaskOne") + : Loc.T("vm.tasksIsland.openTasks", open); if (running > 0 || review > 0) { - StatusPill = $"{running} running · {review} review"; + StatusPill = Loc.T("vm.tasksIsland.statusPill", running, review); HasStatusPill = true; } else diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs index cc292ba7..78b5f5e3 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs @@ -6,6 +6,7 @@ using Avalonia.Interactivity; using Avalonia.VisualTree; using ClaudeDo.Data.Models; using ClaudeDo.Ui.Localization; +using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.ViewModels.Islands; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; @@ -97,6 +98,23 @@ public partial class TaskRowView : UserControl schedule.Items.Add(MakeItem("tasks.ctxRemoveFromMyDay", OnRemoveFromMyDayClick, row.IsMyDay)); menu.Items.Add(schedule); + // "Move to list" — user lists only, current list excluded. Reuses the drag-drop move + // (MoveTaskToListAsync), so nothing but ListId changes and the same running/worktree + // guards apply. Hidden entirely when there is nowhere to move to. + var targets = (TopLevel.GetTopLevel(this)?.DataContext as IslandsShellViewModel)?.Lists?.UserLists + .Where(l => l.Id != vm.CurrentListId).ToList() ?? []; + if (targets.Count > 0) + { + var moveTo = new MenuItem { Header = Loc.T("tasks.ctxMoveToList"), DataContext = row }; + foreach (var target in targets) + { + var item = new MenuItem { Header = target.Name, DataContext = row, Tag = target }; + item.Click += OnMoveToListClick; + moveTo.Items.Add(item); + } + menu.Items.Add(moveTo); + } + menu.Items.Add(new Separator()); menu.Items.Add(MakeItem("tasks.ctxDeleteTask", OnDeleteTaskClick, reason: row.DeleteDisabledReason)); @@ -152,6 +170,13 @@ public partial class TaskRowView : UserControl await vm.RemoveFromMyDayCommand.ExecuteAsync(row); } + private async void OnMoveToListClick(object? sender, RoutedEventArgs e) + { + if (sender is not MenuItem { Tag: ListNavItemViewModel target }) return; + if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm) + await vm.MoveTaskToListAsync(row, target); + } + private async void OnDeleteTaskClick(object? sender, RoutedEventArgs e) { if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)