feat(ui): Task-Zeile entdichten und Kontextmenü gruppieren (UX-Struktur A)

Gruppe 1 (Send to queue, Remove from queue, Cancel execution, Open quick
session, Refine task) ist jetzt fix sichtbar und gated per IsEnabled + Grund
statt komplett auszublenden. Planning/Schedule wandern in Untermenüs (Mark as
existierte bereits); der Planning-Kopf zeigt einen Grund, wenn er sonst leer
wäre. Refine-Button, ungesetzter Star und Dequeue-X erscheinen nur bei
Hover-oder-Selektion (TaskRowViewModel.ShowRowActions); ein gesetzter Star und
der Refining-Spinner bleiben immer sichtbar. Der Branch-Chip wandert aus der
Zeile in eine Meta-Zeile in TaskHeaderBar.

A3 headless verifiziert: Avalonia 12 zeigt ToolTips auf IsEnabled=false
Controls nur mit ToolTip.ShowOnDisabled="True" (Default ist false) — dieses
Attached Property existiert bereits und wird für die neuen Disabled-Reason-
Tooltips genutzt.
This commit is contained in:
mika kuns
2026-08-21 14:34:14 +02:00
parent c3ef460a40
commit 51dc7bd61b
7 changed files with 376 additions and 33 deletions
@@ -58,6 +58,11 @@ public sealed partial class TaskRowViewModel : ViewModelBase
// Set by the custom drag while this row is being dragged — drives the "grabbed" row style.
[ObservableProperty] private bool _isDragging;
// Set from PointerEntered/PointerExited in TaskRowView.axaml.cs. Drives ShowRowActions
// together with IsSelected, so hover-only affordances (refine button, unset star, dequeue-X)
// stay reachable via keyboard/selection, not just mouse hover.
[ObservableProperty] private bool _isHovered;
// Transient: set from HubBroadcaster's OperationProgress while the worker is still creating
// this task's worktree (the silent gap between Queued and the first agent output). Cleared
// by the next entity refresh — UpdateFromEntity always reflects a settled state, so there's
@@ -68,6 +73,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase
// suppress the ordinary hover highlight/transitions so they don't fight the hint.
public bool IsDropTarget => DropHintAbove || DropHintBelow;
// Hover-or-selected gate for the row's optional action affordances (refine button, unset
// star, dequeue-X) — a set star and the IsRefining spinner are exempt, they show always.
public bool ShowRowActions => IsHovered || IsSelected;
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None
&& !IsRefining && !IsManual;
@@ -142,6 +151,60 @@ public sealed partial class TaskRowViewModel : ViewModelBase
&& !HasQueuedSubtasks;
// User-triggered finalize for a planning parent whose session was closed before finalizing.
public bool CanFinalizePlanning => PlanningPhase == PlanningPhase.Active && !IsChild;
// Context-menu Group 1 reasons: null when the paired CanX is true, otherwise the specific
// blocking condition — shown as a disabled MenuItem's tooltip (ToolTip.ShowOnDisabled="True",
// verified headlessly to actually render on Avalonia 12 — see task A3). Each mirrors the
// negation of its CanX so exactly one branch matches whenever CanX is false.
public string? SendToQueueDisabledReason
{
get
{
if (CanSendToQueue) return null;
if (IsRunning) return Loc.T("tasks.reasonAlreadyRunning");
if (IsQueued) return Loc.T("tasks.reasonAlreadyQueued");
if (IsWaitingForReview) return Loc.T("tasks.reasonWaitingForReview");
if (HasQueuedSubtasks) return Loc.T("tasks.reasonSubtasksQueued");
if (IsChild && !ParentFinalized) return Loc.T("tasks.reasonPlanNotFinalized");
if (PlanningPhase == PlanningPhase.Active) return Loc.T("tasks.reasonPlanningActive");
if (IsManual) return Loc.T("tasks.manualTip");
if (HasInteractiveSession) return Loc.T("tasks.reasonInteractiveSession");
return null;
}
}
public string? CancelDisabledReason => IsRunning ? null : Loc.T("tasks.reasonNotRunning");
// "Open quick session" has no precondition today (see A1's file header) — kept as a
// placeholder so the menu wiring is uniform; always null until a real gate exists.
public string? QuickSessionDisabledReason => null;
public string? RefineDisabledReason
{
get
{
if (CanRefine) return null;
if (IsManual) return Loc.T("tasks.manualTip");
if (IsRefining) return Loc.T("tasks.reasonRefining");
if (PlanningPhase != PlanningPhase.None) return Loc.T("tasks.reasonIsPlanningParent");
return Loc.T("tasks.reasonNotIdle");
}
}
// Gates the "Planning" submenu header — null unless Open/Resume-or-Discard/Finalize would
// all be hidden, i.e. the submenu would otherwise open empty.
public string? PlanningDisabledReason
{
get
{
if (CanOpenPlanningSession || CanResumeOrDiscardPlanning || CanFinalizePlanning) return null;
if (IsManual) return Loc.T("tasks.manualTip");
if (IsChild) return Loc.T("tasks.reasonIsChildTask");
if (PlanningPhase == PlanningPhase.Finalized) return Loc.T("tasks.reasonPlanAlreadyFinalized");
return Loc.T("tasks.reasonNotIdle");
}
}
public bool HasSchedule => ScheduledFor.HasValue;
// "Add to My Day" — shown on any task not already in My Day; a Done task has no place in
// today's focus list. The mirror of "Remove from My Day" (gated on IsMyDay).
@@ -243,6 +306,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(CanRemoveFromQueue));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanRefine));
OnPropertyChanged(nameof(SendToQueueDisabledReason));
OnPropertyChanged(nameof(CancelDisabledReason));
OnPropertyChanged(nameof(RefineDisabledReason));
OnPropertyChanged(nameof(PlanningDisabledReason));
}
partial void OnParentTaskIdChanged(string? value)
@@ -254,6 +321,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(IsPlanned));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanOpenPlanningSession));
OnPropertyChanged(nameof(SendToQueueDisabledReason));
OnPropertyChanged(nameof(PlanningDisabledReason));
}
partial void OnParentInViewChanged(bool value)
@@ -270,6 +339,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(IsPlanned));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(SendToQueueDisabledReason));
}
partial void OnPlanningPhaseChanged(PlanningPhase value)
@@ -285,9 +355,16 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanFinalizePlanning));
OnPropertyChanged(nameof(CanRefine));
OnPropertyChanged(nameof(SendToQueueDisabledReason));
OnPropertyChanged(nameof(RefineDisabledReason));
OnPropertyChanged(nameof(PlanningDisabledReason));
}
partial void OnIsRefiningChanged(bool value) => OnPropertyChanged(nameof(CanRefine));
partial void OnIsRefiningChanged(bool value)
{
OnPropertyChanged(nameof(CanRefine));
OnPropertyChanged(nameof(RefineDisabledReason));
}
partial void OnIsManualChanged(bool value)
{
@@ -295,6 +372,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(CanRefine));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanOpenPlanningSession));
OnPropertyChanged(nameof(SendToQueueDisabledReason));
OnPropertyChanged(nameof(RefineDisabledReason));
OnPropertyChanged(nameof(PlanningDisabledReason));
}
partial void OnHasInteractiveSessionChanged(bool value)
@@ -305,6 +385,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(ShowStatusChip));
OnPropertyChanged(nameof(StatusChipTooltip));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(SendToQueueDisabledReason));
}
partial void OnFailureReasonChanged(string? value)
@@ -323,8 +404,12 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(CanRemoveFromQueue));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanQueuePlan));
OnPropertyChanged(nameof(SendToQueueDisabledReason));
}
partial void OnIsHoveredChanged(bool value) => OnPropertyChanged(nameof(ShowRowActions));
partial void OnIsSelectedChanged(bool value) => OnPropertyChanged(nameof(ShowRowActions));
partial void OnBlockedByTaskIdChanged(string? value)
{
OnPropertyChanged(nameof(IsQueued));