Files
ClaudeDo/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs
mika kuns d094a21e09 feat(planning): prevent orphaned subtasks via guards + startup repair
Three coordinated guards close the orphan-creation paths:

- CreateChildAsync refuses when the parent is not in a planning phase.
- DiscardPlanningAsync now returns a structured DiscardPlanningOutcome
  and refuses when children are queued or running; callers can opt into
  auto-dequeuing queued kids via dequeueQueuedChildren=true. Terminal
  children (Done/Failed/Cancelled) are promoted to top-level instead of
  becoming orphans when the parent's PlanningPhase is reset.
- OrphanRecovery hosted service clears ParentTaskId on any rows whose
  parent is missing or no longer in a planning phase on worker startup,
  mirroring the StaleTaskRecovery pattern.

UI surfaces the block reason: a confirm dialog offers to dequeue queued
children and retry; a running-children block is shown as a hard error
asking the user to cancel first.

WorkerClient now negotiates the JsonStringEnumConverter so the
DiscardPlanningResult enum round-trips correctly over SignalR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:02:15 +02:00

228 lines
8.2 KiB
C#

using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.Views.Modals;
namespace ClaudeDo.Ui.Views.Islands;
public partial class TasksIslandView : UserControl
{
private static readonly DataFormat<string> TaskRowFormat =
DataFormat.CreateStringApplicationFormat("claudedo-task-row");
public TasksIslandView()
{
InitializeComponent();
AddHandler(PointerPressedEvent, OnTunnelPointerPressed, RoutingStrategies.Tunnel);
DataContextChanged += (_, _) =>
{
if (DataContext is TasksIslandViewModel vm)
{
vm.FocusAddTaskRequested += (_, _) => AddTaskBox.Focus();
vm.ShowUnfinishedPlanningModal = async (modalVm) =>
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) { modalVm.CancelCommand.Execute(null); return; }
var modal = new UnfinishedPlanningModalView { DataContext = modalVm };
// Closing via the OS title-bar (if ever enabled) also resolves the TCS.
modal.Closed += (_, _) => modalVm.CancelCommand.Execute(null);
await modal.ShowDialog(owner);
// ShowDialog completes once the window is closed (CloseAction or OS close).
};
vm.ConfirmAsync = ShowConfirmAsync;
}
};
}
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;
}
private async void OnTunnelPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (DataContext is not TasksIslandViewModel vm) return;
if (e.Source is not Visual src) return;
var button = src as Button ?? src.FindAncestorOfType<Button>();
if (button?.DataContext is not TaskRowViewModel row) return;
if (!e.GetCurrentPoint(button).Properties.IsLeftButtonPressed) return;
// Select now so the details pane updates whether the gesture becomes a click or a drag.
// (Button.Click doesn't fire once DoDragDropAsync captures the pointer.)
vm.SelectedTask = row;
// If the click landed on a nested Button (e.g. the done-toggle checkbox or star),
// don't start a drag — that would capture the pointer and swallow the inner Click.
var nestedInsideButton = button.Parent is Visual parentVisual
&& parentVisual.FindAncestorOfType<Button>() is not null;
if (nestedInsideButton) return;
if (!vm.CanReorder || row.IsRunning) return;
var data = new DataTransfer();
data.Add(DataTransferItem.Create(TaskRowFormat, row.Id));
try
{
await DragDrop.DoDragDropAsync(e, data, DragDropEffects.Move);
}
finally
{
vm.ClearDropHints();
}
}
private void OnRowPointerPressed(object? sender, PointerPressedEventArgs e) { }
private void OnRowPointerMoved(object? sender, PointerEventArgs e) { }
private void OnRowPointerReleased(object? sender, PointerReleasedEventArgs e) { }
private void OnRowDragOver(object? sender, DragEventArgs e)
{
if (DataContext is not TasksIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; }
if (!e.DataTransfer?.Contains(TaskRowFormat) ?? true)
{
e.DragEffects = DragDropEffects.None;
vm.ClearDropHints();
return;
}
if (sender is not Button b || b.DataContext is not TaskRowViewModel target || target.IsRunning)
{
e.DragEffects = DragDropEffects.None;
vm.ClearDropHints();
return;
}
var sourceId = e.DataTransfer?.TryGetValue(TaskRowFormat);
if (string.IsNullOrEmpty(sourceId) || sourceId == target.Id)
{
e.DragEffects = DragDropEffects.None;
vm.ClearDropHints();
return;
}
var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2;
// Canonicalize: "drop below X" == "drop above X+1". Render the indicator
// above X+1 when there is one; only the last row in a section shows a below-line.
TaskRowViewModel hintRow = target;
bool hintBelow = false;
if (placeBelow)
{
var next = FindNextInSameSection(vm, target);
if (next is not null && !next.IsRunning)
{
hintRow = next;
hintBelow = false;
}
else
{
hintRow = target;
hintBelow = true;
}
}
// A hint that lands right where the dragged row already sits is a no-op.
if (hintRow.Id == sourceId)
{
e.DragEffects = DragDropEffects.None;
vm.ClearDropHints();
return;
}
vm.SetDropHint(hintRow, hintBelow);
e.DragEffects = DragDropEffects.Move;
}
private static TaskRowViewModel? FindNextInSameSection(TasksIslandViewModel vm, TaskRowViewModel row)
{
foreach (var section in new[] { vm.OverdueItems, vm.OpenItems, vm.CompletedItems })
{
var idx = section.IndexOf(row);
if (idx >= 0) return idx + 1 < section.Count ? section[idx + 1] : null;
}
return null;
}
private async void OnRowDrop(object? sender, DragEventArgs e)
{
if (DataContext is not TasksIslandViewModel vm) return;
try
{
if (sender is not Button b || b.DataContext is not TaskRowViewModel target) return;
if (target.IsRunning) return;
var sourceId = e.DataTransfer?.TryGetValue(TaskRowFormat);
if (string.IsNullOrEmpty(sourceId) || sourceId == target.Id) return;
var source = FindRowById(vm, sourceId);
if (source is null || source.IsRunning) return;
var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2;
// Clear the 6px drop-hint spacer BEFORE the move so the reorder animates
// into its truly-final layout in one step (otherwise the row lands in the
// gap, then the gap collapses and everything shifts up a second time).
vm.ClearDropHints();
await vm.ReorderAsync(source, target, placeBelow);
}
catch
{
vm.ClearDropHints();
throw;
}
}
private static TaskRowViewModel? FindRowById(TasksIslandViewModel vm, string id)
{
foreach (var r in vm.Items)
if (r.Id == id) return r;
return null;
}
}