chore(claude-do): merge feat(ui): Task-Zeile entdichten und Kontextmenü gruppieren (
ClaudeDo-Task: a43e22e7-ba96-4ee0-8aa6-482f7c567db6
This commit is contained in:
@@ -167,7 +167,23 @@
|
|||||||
"manualTip": "Manuelle Erinnerung — Claude nimmt sie nie auf",
|
"manualTip": "Manuelle Erinnerung — Claude nimmt sie nie auf",
|
||||||
"ctxMarkManual": "Als manuell markieren",
|
"ctxMarkManual": "Als manuell markieren",
|
||||||
"ctxMarkClaudeTask": "Als Claude-Aufgabe markieren",
|
"ctxMarkClaudeTask": "Als Claude-Aufgabe markieren",
|
||||||
|
"ctxRefineTask": "Aufgabe verfeinern",
|
||||||
|
"ctxPlanning": "Planung",
|
||||||
|
"ctxSchedule": "Zeitplan",
|
||||||
"ctxDeleteTask": "Aufgabe löschen",
|
"ctxDeleteTask": "Aufgabe löschen",
|
||||||
|
"reasonAlreadyRunning": "Aufgabe läuft bereits",
|
||||||
|
"reasonAlreadyQueued": "Bereits in der Warteschlange",
|
||||||
|
"reasonWaitingForReview": "Wartet auf Prüfung",
|
||||||
|
"reasonSubtasksQueued": "Teilaufgaben sind bereits eingereiht",
|
||||||
|
"reasonPlanNotFinalized": "Plan ist noch nicht finalisiert",
|
||||||
|
"reasonPlanningActive": "Planungssitzung ist aktiv — zuerst finalisieren",
|
||||||
|
"reasonInteractiveSession": "Für diese Aufgabe ist eine interaktive Sitzung offen",
|
||||||
|
"reasonNotRunning": "Aufgabe läuft nicht",
|
||||||
|
"reasonRefining": "Verfeinerung läuft bereits",
|
||||||
|
"reasonNotIdle": "Nur im Leerlauf verfügbar",
|
||||||
|
"reasonIsPlanningParent": "Teil eines Plans",
|
||||||
|
"reasonIsChildTask": "Teilaufgaben haben keine eigene Planungssitzung",
|
||||||
|
"reasonPlanAlreadyFinalized": "Plan ist bereits finalisiert",
|
||||||
"approve": "Genehmigen",
|
"approve": "Genehmigen",
|
||||||
"approveTip": "Genehmigen — als Erledigt markieren",
|
"approveTip": "Genehmigen — als Erledigt markieren",
|
||||||
"reject": "Ablehnen",
|
"reject": "Ablehnen",
|
||||||
|
|||||||
@@ -167,7 +167,23 @@
|
|||||||
"manualTip": "Manual reminder — Claude never picks this up",
|
"manualTip": "Manual reminder — Claude never picks this up",
|
||||||
"ctxMarkManual": "Mark as manual",
|
"ctxMarkManual": "Mark as manual",
|
||||||
"ctxMarkClaudeTask": "Mark as Claude task",
|
"ctxMarkClaudeTask": "Mark as Claude task",
|
||||||
|
"ctxRefineTask": "Refine task",
|
||||||
|
"ctxPlanning": "Planning",
|
||||||
|
"ctxSchedule": "Schedule",
|
||||||
"ctxDeleteTask": "Delete task",
|
"ctxDeleteTask": "Delete task",
|
||||||
|
"reasonAlreadyRunning": "Task is already running",
|
||||||
|
"reasonAlreadyQueued": "Already in the queue",
|
||||||
|
"reasonWaitingForReview": "Waiting for review",
|
||||||
|
"reasonSubtasksQueued": "Subtasks are already queued",
|
||||||
|
"reasonPlanNotFinalized": "Plan isn't finalized yet",
|
||||||
|
"reasonPlanningActive": "Planning session is active — finalize the plan first",
|
||||||
|
"reasonInteractiveSession": "An interactive session is open for this task",
|
||||||
|
"reasonNotRunning": "Task isn't running",
|
||||||
|
"reasonRefining": "Refine is already running",
|
||||||
|
"reasonNotIdle": "Only available for idle tasks",
|
||||||
|
"reasonIsPlanningParent": "Part of a plan",
|
||||||
|
"reasonIsChildTask": "Subtasks don't have their own planning session",
|
||||||
|
"reasonPlanAlreadyFinalized": "Plan is already finalized",
|
||||||
"approve": "Approve",
|
"approve": "Approve",
|
||||||
"approveTip": "Approve — mark Done",
|
"approveTip": "Approve — mark Done",
|
||||||
"reject": "Reject",
|
"reject": "Reject",
|
||||||
|
|||||||
@@ -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.
|
// Set by the custom drag while this row is being dragged — drives the "grabbed" row style.
|
||||||
[ObservableProperty] private bool _isDragging;
|
[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
|
// 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
|
// 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
|
// 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.
|
// suppress the ordinary hover highlight/transitions so they don't fight the hint.
|
||||||
public bool IsDropTarget => DropHintAbove || DropHintBelow;
|
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
|
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None
|
||||||
&& !IsRefining && !IsManual;
|
&& !IsRefining && !IsManual;
|
||||||
|
|
||||||
@@ -142,6 +151,60 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
&& !HasQueuedSubtasks;
|
&& !HasQueuedSubtasks;
|
||||||
// User-triggered finalize for a planning parent whose session was closed before finalizing.
|
// User-triggered finalize for a planning parent whose session was closed before finalizing.
|
||||||
public bool CanFinalizePlanning => PlanningPhase == PlanningPhase.Active && !IsChild;
|
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;
|
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
|
// "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).
|
// 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(CanRemoveFromQueue));
|
||||||
OnPropertyChanged(nameof(CanSendToQueue));
|
OnPropertyChanged(nameof(CanSendToQueue));
|
||||||
OnPropertyChanged(nameof(CanRefine));
|
OnPropertyChanged(nameof(CanRefine));
|
||||||
|
OnPropertyChanged(nameof(SendToQueueDisabledReason));
|
||||||
|
OnPropertyChanged(nameof(CancelDisabledReason));
|
||||||
|
OnPropertyChanged(nameof(RefineDisabledReason));
|
||||||
|
OnPropertyChanged(nameof(PlanningDisabledReason));
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnParentTaskIdChanged(string? value)
|
partial void OnParentTaskIdChanged(string? value)
|
||||||
@@ -254,6 +321,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(IsPlanned));
|
OnPropertyChanged(nameof(IsPlanned));
|
||||||
OnPropertyChanged(nameof(CanSendToQueue));
|
OnPropertyChanged(nameof(CanSendToQueue));
|
||||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||||
|
OnPropertyChanged(nameof(SendToQueueDisabledReason));
|
||||||
|
OnPropertyChanged(nameof(PlanningDisabledReason));
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnParentInViewChanged(bool value)
|
partial void OnParentInViewChanged(bool value)
|
||||||
@@ -270,6 +339,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(IsDraft));
|
OnPropertyChanged(nameof(IsDraft));
|
||||||
OnPropertyChanged(nameof(IsPlanned));
|
OnPropertyChanged(nameof(IsPlanned));
|
||||||
OnPropertyChanged(nameof(CanSendToQueue));
|
OnPropertyChanged(nameof(CanSendToQueue));
|
||||||
|
OnPropertyChanged(nameof(SendToQueueDisabledReason));
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnPlanningPhaseChanged(PlanningPhase value)
|
partial void OnPlanningPhaseChanged(PlanningPhase value)
|
||||||
@@ -285,9 +355,16 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(CanSendToQueue));
|
OnPropertyChanged(nameof(CanSendToQueue));
|
||||||
OnPropertyChanged(nameof(CanFinalizePlanning));
|
OnPropertyChanged(nameof(CanFinalizePlanning));
|
||||||
OnPropertyChanged(nameof(CanRefine));
|
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)
|
partial void OnIsManualChanged(bool value)
|
||||||
{
|
{
|
||||||
@@ -295,6 +372,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(CanRefine));
|
OnPropertyChanged(nameof(CanRefine));
|
||||||
OnPropertyChanged(nameof(CanSendToQueue));
|
OnPropertyChanged(nameof(CanSendToQueue));
|
||||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||||
|
OnPropertyChanged(nameof(SendToQueueDisabledReason));
|
||||||
|
OnPropertyChanged(nameof(RefineDisabledReason));
|
||||||
|
OnPropertyChanged(nameof(PlanningDisabledReason));
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnHasInteractiveSessionChanged(bool value)
|
partial void OnHasInteractiveSessionChanged(bool value)
|
||||||
@@ -305,6 +385,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(ShowStatusChip));
|
OnPropertyChanged(nameof(ShowStatusChip));
|
||||||
OnPropertyChanged(nameof(StatusChipTooltip));
|
OnPropertyChanged(nameof(StatusChipTooltip));
|
||||||
OnPropertyChanged(nameof(CanSendToQueue));
|
OnPropertyChanged(nameof(CanSendToQueue));
|
||||||
|
OnPropertyChanged(nameof(SendToQueueDisabledReason));
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnFailureReasonChanged(string? value)
|
partial void OnFailureReasonChanged(string? value)
|
||||||
@@ -323,8 +404,12 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
||||||
OnPropertyChanged(nameof(CanSendToQueue));
|
OnPropertyChanged(nameof(CanSendToQueue));
|
||||||
OnPropertyChanged(nameof(CanQueuePlan));
|
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)
|
partial void OnBlockedByTaskIdChanged(string? value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(IsQueued));
|
OnPropertyChanged(nameof(IsQueued));
|
||||||
|
|||||||
@@ -25,6 +25,16 @@
|
|||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
AcceptsReturn="False"
|
AcceptsReturn="False"
|
||||||
Padding="0"/>
|
Padding="0"/>
|
||||||
|
|
||||||
|
<!-- Branch meta line: moved here from the task row's chip (A5) — the row no longer
|
||||||
|
shows the branch anywhere else. -->
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,4,0,0"
|
||||||
|
IsVisible="{Binding Task.HasBranch}">
|
||||||
|
<PathIcon Width="10" Height="10"
|
||||||
|
Data="{StaticResource Icon.GitBranch}"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
<TextBlock Classes="meta" Text="{Binding Task.Branch}"/>
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Column 1: trash button (not running) -->
|
<!-- Column 1: trash button (not running) -->
|
||||||
|
|||||||
@@ -41,7 +41,9 @@
|
|||||||
Classes.dragging="{Binding IsDragging}"
|
Classes.dragging="{Binding IsDragging}"
|
||||||
Classes.drop-target="{Binding IsDropTarget}"
|
Classes.drop-target="{Binding IsDropTarget}"
|
||||||
Classes.done="{Binding Done}"
|
Classes.done="{Binding Done}"
|
||||||
ContextRequested="OnRowContextRequested">
|
ContextRequested="OnRowContextRequested"
|
||||||
|
PointerEntered="OnRowPointerEntered"
|
||||||
|
PointerExited="OnRowPointerExited">
|
||||||
<Grid ColumnDefinitions="0,18,32,*,Auto,Auto,32" Margin="6,8,10,8">
|
<Grid ColumnDefinitions="0,18,32,*,Auto,Auto,32" Margin="6,8,10,8">
|
||||||
|
|
||||||
<!-- Chevron toggle (only for planning parent tasks) -->
|
<!-- Chevron toggle (only for planning parent tasks) -->
|
||||||
@@ -149,12 +151,18 @@
|
|||||||
<TextBlock Text="{Binding CreationPhaseLabel}"/>
|
<TextBlock Text="{Binding CreationPhaseLabel}"/>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Dequeue button (visible when row is Queued, or planning parent has queued subtasks) -->
|
<!-- Dequeue button (visible when row is Queued, or planning parent has queued subtasks,
|
||||||
|
AND the row is hovered or selected) -->
|
||||||
<Button Classes="icon-btn dequeue-btn"
|
<Button Classes="icon-btn dequeue-btn"
|
||||||
IsVisible="{Binding CanRemoveFromQueue}"
|
|
||||||
ToolTip.Tip="{loc:Tr tasks.removeFromQueueTip}"
|
ToolTip.Tip="{loc:Tr tasks.removeFromQueueTip}"
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RemoveFromQueueCommand}"
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RemoveFromQueueCommand}"
|
||||||
CommandParameter="{Binding}">
|
CommandParameter="{Binding}">
|
||||||
|
<Button.IsVisible>
|
||||||
|
<MultiBinding Converter="{x:Static BoolConverters.And}">
|
||||||
|
<Binding Path="CanRemoveFromQueue"/>
|
||||||
|
<Binding Path="ShowRowActions"/>
|
||||||
|
</MultiBinding>
|
||||||
|
</Button.IsVisible>
|
||||||
<PathIcon Width="10" Height="10" Data="{StaticResource Icon.X}"/>
|
<PathIcon Width="10" Height="10" Data="{StaticResource Icon.X}"/>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -168,16 +176,6 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Branch chip -->
|
|
||||||
<Border Classes="chip chip-branch" IsVisible="{Binding HasBranch}">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
|
||||||
<PathIcon Width="10" Height="10"
|
|
||||||
Data="{StaticResource Icon.GitBranch}"
|
|
||||||
Foreground="{DynamicResource TextDimBrush}"/>
|
|
||||||
<TextBlock Text="{Binding Branch}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Diff chip -->
|
<!-- Diff chip -->
|
||||||
<Border Classes="chip chip-diff" IsVisible="{Binding HasDiff}">
|
<Border Classes="chip chip-diff" IsVisible="{Binding HasDiff}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
||||||
@@ -203,11 +201,16 @@
|
|||||||
<!-- Refine button, replaced by a spinner while the refine run is in flight -->
|
<!-- Refine button, replaced by a spinner while the refine run is in flight -->
|
||||||
<Panel Grid.Column="5">
|
<Panel Grid.Column="5">
|
||||||
<Button Classes="icon-btn refine-btn"
|
<Button Classes="icon-btn refine-btn"
|
||||||
IsVisible="{Binding CanRefine}"
|
|
||||||
VerticalAlignment="Top" Margin="0,2,0,0"
|
VerticalAlignment="Top" Margin="0,2,0,0"
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RefineTaskCommand}"
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RefineTaskCommand}"
|
||||||
CommandParameter="{Binding}"
|
CommandParameter="{Binding}"
|
||||||
ToolTip.Tip="{loc:Tr tasks.refineTip}">
|
ToolTip.Tip="{loc:Tr tasks.refineTip}">
|
||||||
|
<Button.IsVisible>
|
||||||
|
<MultiBinding Converter="{x:Static BoolConverters.And}">
|
||||||
|
<Binding Path="CanRefine"/>
|
||||||
|
<Binding Path="ShowRowActions"/>
|
||||||
|
</MultiBinding>
|
||||||
|
</Button.IsVisible>
|
||||||
<Viewbox Width="16" Height="16">
|
<Viewbox Width="16" Height="16">
|
||||||
<Path Classes="plan-icon" Data="{StaticResource Icon.Refine}"/>
|
<Path Classes="plan-icon" Data="{StaticResource Icon.Refine}"/>
|
||||||
</Viewbox>
|
</Viewbox>
|
||||||
@@ -218,13 +221,19 @@
|
|||||||
ToolTip.Tip="{loc:Tr tasks.refiningTip}"/>
|
ToolTip.Tip="{loc:Tr tasks.refiningTip}"/>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- Star toggle -->
|
<!-- Star toggle: a set star always shows; unset, only on hover/selection -->
|
||||||
<Button Grid.Column="6" Classes="icon-btn star-btn"
|
<Button Grid.Column="6" Classes="icon-btn star-btn"
|
||||||
Classes.on="{Binding IsStarred}"
|
Classes.on="{Binding IsStarred}"
|
||||||
VerticalAlignment="Top" Margin="0,2,0,0"
|
VerticalAlignment="Top" Margin="0,2,0,0"
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleStarCommand}"
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleStarCommand}"
|
||||||
CommandParameter="{Binding}"
|
CommandParameter="{Binding}"
|
||||||
ToolTip.Tip="{loc:Tr details.starTip}">
|
ToolTip.Tip="{loc:Tr details.starTip}">
|
||||||
|
<Button.IsVisible>
|
||||||
|
<MultiBinding Converter="{x:Static BoolConverters.Or}">
|
||||||
|
<Binding Path="IsStarred"/>
|
||||||
|
<Binding Path="ShowRowActions"/>
|
||||||
|
</MultiBinding>
|
||||||
|
</Button.IsVisible>
|
||||||
<PathIcon Width="14" Height="14" Data="{StaticResource Icon.Star}"/>
|
<PathIcon Width="14" Height="14" Data="{StaticResource Icon.Star}"/>
|
||||||
</Button>
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ public partial class TaskRowView : UserControl
|
|||||||
this.GetVisualAncestors().OfType<ItemsControl>()
|
this.GetVisualAncestors().OfType<ItemsControl>()
|
||||||
.Select(ic => ic.DataContext).OfType<TasksIslandViewModel>().FirstOrDefault();
|
.Select(ic => ic.DataContext).OfType<TasksIslandViewModel>().FirstOrDefault();
|
||||||
|
|
||||||
// The context menu is built here instead of declared in XAML so the ~18 MenuItems only
|
// The context menu is built here instead of declared in XAML so the MenuItems only exist
|
||||||
// exist while a row's menu is actually open, not for every row rendered in the list.
|
// while a row's menu is actually open, not for every row rendered in the list.
|
||||||
private void OnRowContextRequested(object? sender, ContextRequestedEventArgs e)
|
private void OnRowContextRequested(object? sender, ContextRequestedEventArgs e)
|
||||||
{
|
{
|
||||||
if (sender is not Border border) return;
|
if (sender is not Border border) return;
|
||||||
@@ -35,19 +35,36 @@ public partial class TaskRowView : UserControl
|
|||||||
|
|
||||||
var menu = new ContextMenu { DataContext = row };
|
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 };
|
var item = new MenuItem { Header = Loc.T(key), DataContext = row, IsVisible = isVisible };
|
||||||
if (tag is not null) item.Tag = tag;
|
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;
|
item.Click += click;
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
menu.Items.Add(MakeItem("tasks.ctxSendToQueue", OnSendToQueueClick, row.CanSendToQueue));
|
// Group 1 — fixed length and order, always visible; an unmet precondition grays the
|
||||||
menu.Items.Add(MakeItem("tasks.ctxRemoveFromQueue", OnRemoveFromQueueClick, row.CanRemoveFromQueue));
|
// entry out instead of hiding it (Remove from queue has no dedicated reason text, only
|
||||||
menu.Items.Add(MakeItem("tasks.ctxCancelExecution", OnCancelExecutionClick, row.IsRunning));
|
// 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());
|
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 };
|
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.ctxMarkDone", OnSetStatusClick, tag: "Done"));
|
||||||
markAs.Items.Add(MakeItem("tasks.ctxMarkCancelled", OnSetStatusClick, tag: "Cancelled"));
|
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));
|
markAs.Items.Add(MakeItem("tasks.ctxMarkClaudeTask", OnToggleManualClick, row.IsManual));
|
||||||
menu.Items.Add(markAs);
|
menu.Items.Add(markAs);
|
||||||
|
|
||||||
menu.Items.Add(new Separator());
|
// "Planning" — the header itself disables + explains when it would otherwise open empty.
|
||||||
menu.Items.Add(MakeItem("tasks.ctxOpenConPtySession", OnOpenConPtySessionClick));
|
var planning = new MenuItem { Header = Loc.T("tasks.ctxPlanning"), DataContext = row };
|
||||||
menu.Items.Add(MakeItem("tasks.ctxOpenPlanningSession", OnOpenPlanningSessionClick, row.CanOpenPlanningSession));
|
if (row.PlanningDisabledReason is { } planningReason)
|
||||||
menu.Items.Add(MakeItem("tasks.ctxResumePlanningSession", OnResumePlanningSessionClick, row.CanResumeOrDiscardPlanning));
|
{
|
||||||
menu.Items.Add(MakeItem("tasks.ctxFinalizePlanningSession", OnFinalizePlanningSessionClick, row.CanFinalizePlanning));
|
planning.IsEnabled = false;
|
||||||
menu.Items.Add(MakeItem("tasks.ctxDiscardPlanningSession", OnDiscardPlanningSessionClick, row.CanResumeOrDiscardPlanning));
|
ToolTip.SetTip(planning, planningReason);
|
||||||
menu.Items.Add(new Separator());
|
ToolTip.SetShowOnDisabled(planning, true);
|
||||||
menu.Items.Add(MakeItem("tasks.ctxScheduleFor", OnScheduleForClick));
|
}
|
||||||
menu.Items.Add(MakeItem("tasks.ctxClearSchedule", OnClearScheduleClick, row.HasSchedule));
|
planning.Items.Add(MakeItem("tasks.ctxOpenPlanningSession", OnOpenPlanningSessionClick, row.CanOpenPlanningSession));
|
||||||
menu.Items.Add(MakeItem("tasks.ctxAddToMyDay", OnAddToMyDayClick, row.CanAddToMyDay));
|
planning.Items.Add(MakeItem("tasks.ctxResumePlanningSession", OnResumePlanningSessionClick, row.CanResumeOrDiscardPlanning));
|
||||||
menu.Items.Add(MakeItem("tasks.ctxRemoveFromMyDay", OnRemoveFromMyDayClick, row.IsMyDay));
|
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(new Separator());
|
||||||
menu.Items.Add(MakeItem("tasks.ctxDeleteTask", OnDeleteTaskClick));
|
menu.Items.Add(MakeItem("tasks.ctxDeleteTask", OnDeleteTaskClick));
|
||||||
|
|
||||||
@@ -92,6 +121,12 @@ public partial class TaskRowView : UserControl
|
|||||||
await vm.CancelRunningTaskCommand.ExecuteAsync(row);
|
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)
|
private async void OnClearScheduleClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||||
@@ -194,4 +229,14 @@ public partial class TaskRowView : UserControl
|
|||||||
ScheduleAnchor.Flyout?.Hide();
|
ScheduleAnchor.Flyout?.Hide();
|
||||||
_pendingScheduleRow = null;
|
_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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
using Xunit;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// [UX-Struktur A2/A4] Group 1's disabled-reason properties (grayed out with an explanation
|
||||||
|
/// instead of hidden — see task A1) and the hover-or-selected gate for the optional row actions.
|
||||||
|
/// </summary>
|
||||||
|
public class TaskRowContextMenuTests
|
||||||
|
{
|
||||||
|
private static TaskRowViewModel MakeRow() => new() { Id = "t1" };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SendToQueueDisabledReason_Null_When_Sendable()
|
||||||
|
{
|
||||||
|
var row = MakeRow();
|
||||||
|
row.Status = TaskStatus.Idle;
|
||||||
|
Assert.True(row.CanSendToQueue);
|
||||||
|
Assert.Null(row.SendToQueueDisabledReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SendToQueueDisabledReason_Set_When_AlreadyQueued()
|
||||||
|
{
|
||||||
|
var row = MakeRow();
|
||||||
|
row.Status = TaskStatus.Queued;
|
||||||
|
Assert.False(row.CanSendToQueue);
|
||||||
|
Assert.NotNull(row.SendToQueueDisabledReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SendToQueueDisabledReason_Set_When_Manual()
|
||||||
|
{
|
||||||
|
var row = MakeRow();
|
||||||
|
row.Status = TaskStatus.Idle;
|
||||||
|
row.IsManual = true;
|
||||||
|
Assert.False(row.CanSendToQueue);
|
||||||
|
Assert.NotNull(row.SendToQueueDisabledReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancelDisabledReason_Null_While_Running()
|
||||||
|
{
|
||||||
|
var row = MakeRow();
|
||||||
|
row.Status = TaskStatus.Running;
|
||||||
|
Assert.Null(row.CancelDisabledReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancelDisabledReason_Set_When_NotRunning()
|
||||||
|
{
|
||||||
|
var row = MakeRow();
|
||||||
|
row.Status = TaskStatus.Idle;
|
||||||
|
Assert.NotNull(row.CancelDisabledReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(TaskStatus.Idle)]
|
||||||
|
[InlineData(TaskStatus.Running)]
|
||||||
|
[InlineData(TaskStatus.Done)]
|
||||||
|
public void QuickSessionDisabledReason_Is_Always_Null(TaskStatus status)
|
||||||
|
{
|
||||||
|
var row = MakeRow();
|
||||||
|
row.Status = status;
|
||||||
|
row.IsManual = true;
|
||||||
|
Assert.Null(row.QuickSessionDisabledReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RefineDisabledReason_Null_When_Refinable()
|
||||||
|
{
|
||||||
|
var row = MakeRow();
|
||||||
|
row.Status = TaskStatus.Idle;
|
||||||
|
Assert.True(row.CanRefine);
|
||||||
|
Assert.Null(row.RefineDisabledReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RefineDisabledReason_Set_When_Manual()
|
||||||
|
{
|
||||||
|
var row = MakeRow();
|
||||||
|
row.Status = TaskStatus.Idle;
|
||||||
|
row.IsManual = true;
|
||||||
|
Assert.False(row.CanRefine);
|
||||||
|
Assert.NotNull(row.RefineDisabledReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RefineDisabledReason_Set_When_Refining()
|
||||||
|
{
|
||||||
|
var row = MakeRow();
|
||||||
|
row.Status = TaskStatus.Idle;
|
||||||
|
row.IsRefining = true;
|
||||||
|
Assert.False(row.CanRefine);
|
||||||
|
Assert.NotNull(row.RefineDisabledReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RefineDisabledReason_Set_When_NotIdle()
|
||||||
|
{
|
||||||
|
var row = MakeRow();
|
||||||
|
row.Status = TaskStatus.Running;
|
||||||
|
Assert.False(row.CanRefine);
|
||||||
|
Assert.NotNull(row.RefineDisabledReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlanningDisabledReason_Null_When_OpenAvailable()
|
||||||
|
{
|
||||||
|
var row = MakeRow();
|
||||||
|
row.Status = TaskStatus.Idle;
|
||||||
|
Assert.True(row.CanOpenPlanningSession);
|
||||||
|
Assert.Null(row.PlanningDisabledReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlanningDisabledReason_Null_When_PlanningActive()
|
||||||
|
{
|
||||||
|
var row = MakeRow();
|
||||||
|
row.Status = TaskStatus.Idle;
|
||||||
|
row.PlanningPhase = PlanningPhase.Active;
|
||||||
|
Assert.True(row.CanResumeOrDiscardPlanning);
|
||||||
|
Assert.Null(row.PlanningDisabledReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlanningDisabledReason_Set_When_Finalized()
|
||||||
|
{
|
||||||
|
var row = MakeRow();
|
||||||
|
row.Status = TaskStatus.Idle;
|
||||||
|
row.PlanningPhase = PlanningPhase.Finalized;
|
||||||
|
Assert.False(row.CanOpenPlanningSession);
|
||||||
|
Assert.False(row.CanResumeOrDiscardPlanning);
|
||||||
|
Assert.False(row.CanFinalizePlanning);
|
||||||
|
Assert.NotNull(row.PlanningDisabledReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlanningDisabledReason_Set_When_Manual()
|
||||||
|
{
|
||||||
|
var row = MakeRow();
|
||||||
|
row.Status = TaskStatus.Idle;
|
||||||
|
row.IsManual = true;
|
||||||
|
Assert.NotNull(row.PlanningDisabledReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false, false, false)]
|
||||||
|
[InlineData(true, false, true)]
|
||||||
|
[InlineData(false, true, true)]
|
||||||
|
[InlineData(true, true, true)]
|
||||||
|
public void ShowRowActions_Follows_Hover_Or_Selection(bool isHovered, bool isSelected, bool expected)
|
||||||
|
{
|
||||||
|
var row = MakeRow();
|
||||||
|
row.IsHovered = isHovered;
|
||||||
|
row.IsSelected = isSelected;
|
||||||
|
Assert.Equal(expected, row.ShowRowActions);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user