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() .Select(ic => ic.DataContext).OfType().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 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)); 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(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 ?? 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; } }