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
@@ -21,8 +21,8 @@ public partial class TaskRowView : UserControl
this.GetVisualAncestors().OfType<ItemsControl>()
.Select(ic => ic.DataContext).OfType<TasksIslandViewModel>().FirstOrDefault();
// The context menu is built here instead of declared in XAML so the ~18 MenuItems only
// exist while a row's menu is actually open, not for every row rendered in the list.
// 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;
@@ -35,19 +35,36 @@ public partial class TaskRowView : UserControl
var menu = new ContextMenu { DataContext = row };
MenuItem MakeItem(string key, EventHandler<RoutedEventArgs> click, bool isVisible = true, string? tag = null)
// 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;
}
menu.Items.Add(MakeItem("tasks.ctxSendToQueue", OnSendToQueueClick, row.CanSendToQueue));
menu.Items.Add(MakeItem("tasks.ctxRemoveFromQueue", OnRemoveFromQueueClick, row.CanRemoveFromQueue));
menu.Items.Add(MakeItem("tasks.ctxCancelExecution", OnCancelExecutionClick, row.IsRunning));
// 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.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"));
markAs.Items.Add(MakeItem("tasks.ctxMarkCancelled", OnSetStatusClick, tag: "Cancelled"));
@@ -56,17 +73,29 @@ public partial class TaskRowView : UserControl
markAs.Items.Add(MakeItem("tasks.ctxMarkClaudeTask", OnToggleManualClick, row.IsManual));
menu.Items.Add(markAs);
menu.Items.Add(new Separator());
menu.Items.Add(MakeItem("tasks.ctxOpenConPtySession", OnOpenConPtySessionClick));
menu.Items.Add(MakeItem("tasks.ctxOpenPlanningSession", OnOpenPlanningSessionClick, row.CanOpenPlanningSession));
menu.Items.Add(MakeItem("tasks.ctxResumePlanningSession", OnResumePlanningSessionClick, row.CanResumeOrDiscardPlanning));
menu.Items.Add(MakeItem("tasks.ctxFinalizePlanningSession", OnFinalizePlanningSessionClick, row.CanFinalizePlanning));
menu.Items.Add(MakeItem("tasks.ctxDiscardPlanningSession", OnDiscardPlanningSessionClick, row.CanResumeOrDiscardPlanning));
menu.Items.Add(new Separator());
menu.Items.Add(MakeItem("tasks.ctxScheduleFor", OnScheduleForClick));
menu.Items.Add(MakeItem("tasks.ctxClearSchedule", OnClearScheduleClick, row.HasSchedule));
menu.Items.Add(MakeItem("tasks.ctxAddToMyDay", OnAddToMyDayClick, row.CanAddToMyDay));
menu.Items.Add(MakeItem("tasks.ctxRemoveFromMyDay", OnRemoveFromMyDayClick, row.IsMyDay));
// "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));
@@ -92,6 +121,12 @@ public partial class TaskRowView : UserControl
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)
@@ -194,4 +229,14 @@ public partial class TaskRowView : UserControl
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;
}
}