Files
ClaudeDo/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs
T
Mika Kuns c1ab042d0a fix(ui): block delete on interactive-session tasks, clear ghost selection
Delete (row context menu + detail header) now gates on HasInteractiveSession
the same way SendToQueue already does, so an open ConPTY session's claude
process can't be orphaned by deleting its task out from under it.

Row removal (in-UI delete and the delta-refresh path for externally deleted
tasks) now clears SelectedTask via SelectFrom(null, "row-removed") when the
removed row was selected, so the detail pane no longer keeps showing a
deleted task with live buttons.
2026-08-26 15:46:40 +02:00

250 lines
11 KiB
C#

using System.Linq;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.ViewModels.Islands;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.Views.Islands;
public partial class TaskRowView : UserControl
{
private TaskRowViewModel? _pendingScheduleRow;
public TaskRowView() { InitializeComponent(); }
private TasksIslandViewModel? FindTasksVm() =>
this.GetVisualAncestors().OfType<ItemsControl>()
.Select(ic => ic.DataContext).OfType<TasksIslandViewModel>().FirstOrDefault();
// The context menu is built here instead of declared in XAML so the MenuItems only exist
// while a row's menu is actually open, not for every row rendered in the list.
private void OnRowContextRequested(object? sender, ContextRequestedEventArgs e)
{
if (sender is not Border border) return;
if (DataContext is not TaskRowViewModel row) return;
if (FindTasksVm() is not { } vm) return;
// OnTunnelPointerPressed (TasksIslandView) only selects on the left button, so
// right-click needs its own explicit selection before the menu opens.
vm.SelectFrom(row, "context-menu");
var menu = new ContextMenu { DataContext = row };
// reason non-null => IsEnabled=false with that text as the tooltip (ToolTip.ShowOnDisabled
// renders it even though the item is disabled — verified headlessly on Avalonia 12, see
// task A3). Used for Group 1's four gated entries; submenu items keep isVisible hiding.
MenuItem MakeItem(string key, EventHandler<RoutedEventArgs> click, bool isVisible = true, string? tag = null, string? reason = null)
{
var item = new MenuItem { Header = Loc.T(key), DataContext = row, IsVisible = isVisible };
if (tag is not null) item.Tag = tag;
if (reason is not null)
{
item.IsEnabled = false;
ToolTip.SetTip(item, reason);
ToolTip.SetShowOnDisabled(item, true);
}
item.Click += click;
return item;
}
// Group 1 — fixed length and order, always visible; an unmet precondition grays the
// entry out instead of hiding it (Remove from queue has no dedicated reason text, only
// the IsEnabled toggle).
menu.Items.Add(MakeItem("tasks.ctxSendToQueue", OnSendToQueueClick, reason: row.SendToQueueDisabledReason));
var removeFromQueueItem = MakeItem("tasks.ctxRemoveFromQueue", OnRemoveFromQueueClick);
removeFromQueueItem.IsEnabled = row.CanRemoveFromQueue;
menu.Items.Add(removeFromQueueItem);
menu.Items.Add(MakeItem("tasks.ctxCancelExecution", OnCancelExecutionClick, reason: row.CancelDisabledReason));
menu.Items.Add(MakeItem("tasks.ctxOpenConPtySession", OnOpenConPtySessionClick, reason: row.QuickSessionDisabledReason));
menu.Items.Add(MakeItem("tasks.ctxOpenWorktree", OnOpenWorktreeClick, reason: row.OpenWorktreeDisabledReason));
menu.Items.Add(MakeItem("tasks.ctxRefineTask", OnRefineTaskClick, reason: row.RefineDisabledReason));
menu.Items.Add(new Separator());
// "Mark as" — exclusive-choice hiding inside is unchanged; the header is never empty.
var markAs = new MenuItem { Header = Loc.T("tasks.ctxMarkAs"), DataContext = row };
markAs.Items.Add(MakeItem("tasks.ctxMarkDone", OnSetStatusClick, tag: "Done", reason: row.MarkAsDisabledReason));
markAs.Items.Add(MakeItem("tasks.ctxMarkCancelled", OnSetStatusClick, tag: "Cancelled", reason: row.MarkAsDisabledReason));
markAs.Items.Add(new Separator());
markAs.Items.Add(MakeItem("tasks.ctxMarkManual", OnToggleManualClick, !row.IsManual));
markAs.Items.Add(MakeItem("tasks.ctxMarkClaudeTask", OnToggleManualClick, row.IsManual));
menu.Items.Add(markAs);
// "Planning" — the header itself disables + explains when it would otherwise open empty.
var planning = new MenuItem { Header = Loc.T("tasks.ctxPlanning"), DataContext = row };
if (row.PlanningDisabledReason is { } planningReason)
{
planning.IsEnabled = false;
ToolTip.SetTip(planning, planningReason);
ToolTip.SetShowOnDisabled(planning, true);
}
planning.Items.Add(MakeItem("tasks.ctxOpenPlanningSession", OnOpenPlanningSessionClick, row.CanOpenPlanningSession));
planning.Items.Add(MakeItem("tasks.ctxResumePlanningSession", OnResumePlanningSessionClick, row.CanResumeOrDiscardPlanning));
planning.Items.Add(MakeItem("tasks.ctxFinalizePlanningSession", OnFinalizePlanningSessionClick, row.CanFinalizePlanning));
planning.Items.Add(MakeItem("tasks.ctxDiscardPlanningSession", OnDiscardPlanningSessionClick, row.CanResumeOrDiscardPlanning));
menu.Items.Add(planning);
// "Schedule" — always has at least "Schedule for…", so the header is never empty.
var schedule = new MenuItem { Header = Loc.T("tasks.ctxSchedule"), DataContext = row };
schedule.Items.Add(MakeItem("tasks.ctxScheduleFor", OnScheduleForClick));
schedule.Items.Add(MakeItem("tasks.ctxClearSchedule", OnClearScheduleClick, row.HasSchedule));
schedule.Items.Add(new Separator());
schedule.Items.Add(MakeItem("tasks.ctxAddToMyDay", OnAddToMyDayClick, row.CanAddToMyDay));
schedule.Items.Add(MakeItem("tasks.ctxRemoveFromMyDay", OnRemoveFromMyDayClick, row.IsMyDay));
menu.Items.Add(schedule);
menu.Items.Add(new Separator());
menu.Items.Add(MakeItem("tasks.ctxDeleteTask", OnDeleteTaskClick, reason: row.DeleteDisabledReason));
menu.Open(border);
e.Handled = true;
}
private async void OnSendToQueueClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.SendToQueueCommand.ExecuteAsync(row);
}
private async void OnRemoveFromQueueClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.RemoveFromQueueCommand.ExecuteAsync(row);
}
private async void OnCancelExecutionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.CancelRunningTaskCommand.ExecuteAsync(row);
}
private async void OnRefineTaskClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.RefineTaskCommand.ExecuteAsync(row);
}
private async void OnClearScheduleClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.ClearScheduleCommand.ExecuteAsync(row);
}
private async void OnToggleManualClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.ToggleManualCommand.ExecuteAsync(row);
}
private async void OnAddToMyDayClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.AddToMyDayCommand.ExecuteAsync(row);
}
private async void OnRemoveFromMyDayClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
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)
vm.OpenPlanningSessionCommand.Execute(row);
}
private void OnOpenConPtySessionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
vm.OpenConPtySessionCommand.Execute(row);
}
private void OnOpenWorktreeClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
vm.OpenTaskWorktreeCommand.Execute(row);
}
// The status chip is only actionable while a ConPTY session is open; the open command dedupes
// by task id, so it surfaces Mission Control and focuses the existing pane.
private void OnStatusChipTapped(object? sender, RoutedEventArgs e)
{
if (DataContext is not TaskRowViewModel { HasInteractiveSession: true } row) return;
if (FindTasksVm() is { } vm)
vm.OpenConPtySessionCommand.Execute(row);
}
private async void OnResumePlanningSessionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.ResumePlanningSessionCommand.ExecuteAsync(row);
}
private async void OnDiscardPlanningSessionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.DiscardPlanningSessionCommand.ExecuteAsync(row);
}
private async void OnFinalizePlanningSessionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.FinalizePlanningSessionCommand.ExecuteAsync(row);
}
private async void OnSetStatusClick(object? sender, RoutedEventArgs e)
{
if (sender is not MenuItem mi) return;
if (DataContext is not TaskRowViewModel row) return;
if (FindTasksVm() is not { } vm) return;
if (mi.Tag is not string tag) return;
if (!System.Enum.TryParse<TaskStatus>(tag, ignoreCase: true, out var status)) return;
await vm.SetStatusOnRowAsync(row, status);
}
private void OnScheduleForClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not TaskRowViewModel row) return;
_pendingScheduleRow = row;
ScheduleDate.SelectedDate = row.ScheduledFor?.ToLocalTime() ?? DateTime.Now.AddHours(1);
ScheduleAnchor.Flyout?.ShowAt(ScheduleAnchor);
}
private async void OnScheduleSetClick(object? sender, RoutedEventArgs e)
{
ScheduleAnchor.Flyout?.Hide();
if (_pendingScheduleRow is null || ScheduleDate.SelectedDate is null) return;
var when = ScheduleDate.SelectedDate.Value;
if (FindTasksVm() is { } tvm)
await tvm.SetScheduledForAsync(_pendingScheduleRow, when);
_pendingScheduleRow = null;
}
private void OnScheduleCancelClick(object? sender, RoutedEventArgs e)
{
ScheduleAnchor.Flyout?.Hide();
_pendingScheduleRow = null;
}
private void OnRowPointerEntered(object? sender, PointerEventArgs e)
{
if (DataContext is TaskRowViewModel row) row.IsHovered = true;
}
private void OnRowPointerExited(object? sender, PointerEventArgs e)
{
if (DataContext is TaskRowViewModel row) row.IsHovered = false;
}
}