Merge claudedo/1891227787ae4085a52c112bd1dcd75a

This commit is contained in:
mika kuns
2026-08-10 15:05:53 +02:00
13 changed files with 259 additions and 100 deletions
+14 -5
View File
@@ -484,12 +484,21 @@
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<!-- "Grabbed" row: lift + slight scale + lower opacity + shadow while the custom drag runs. -->
<!-- "Grabbed" row: collapses out of layout entirely while the custom drag runs — the
floating ghost snapshot (TaskDragController) is what the user sees moving. -->
<Style Selector="Border.task-row.dragging">
<Setter Property="Opacity" Value="0.55" />
<Setter Property="RenderTransform" Value="scale(1.03)" />
<Setter Property="BoxShadow" Value="0 10 26 0 #66000000" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
<Setter Property="IsVisible" Value="False" />
<Setter Property="Transitions" Value="{x:Null}" />
</Style>
<!-- Row currently hovered while dragging (shows the dashed drop-hint gap): suppress the
ordinary hover highlight and transitions so they don't fight the hint. Placed after
Border.task-row:pointerover so its extra class also wins on declaration order. -->
<Style Selector="Border.task-row.drop-target">
<Setter Property="Transitions" Value="{x:Null}" />
</Style>
<Style Selector="Border.task-row.drop-target:pointerover">
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
</Style>
<!-- Checkbox indicator (the 18px circle that replaces the native CheckBox template) -->
@@ -45,6 +45,10 @@ 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;
// True while a drag is hovering this row (i.e. it would show a drop-hint gap). Used to
// suppress the ordinary hover highlight/transitions so they don't fight the hint.
public bool IsDropTarget => DropHintAbove || DropHintBelow;
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None
&& !IsRefining && !IsManual;
@@ -289,6 +293,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffAdditionsText)); }
partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); }
partial void OnRoadblockCountChanged(int value) { OnPropertyChanged(nameof(HasRoadblock)); OnPropertyChanged(nameof(RoadblockTooltip)); }
partial void OnDropHintAboveChanged(bool value) => OnPropertyChanged(nameof(IsDropTarget));
partial void OnDropHintBelowChanged(bool value) => OnPropertyChanged(nameof(IsDropTarget));
public void RefreshLocalized()
{
@@ -336,26 +336,53 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
try
{
await using var db = await _dbFactory.CreateDbContextAsync(ct);
var all = await db.Tasks
.Include(t => t.List)
.Include(t => t.Worktree)
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.ToListAsync(ct);
var filter = _filters.Resolve(list.Id);
var baseQuery = db.Tasks.Include(t => t.List).Include(t => t.Worktree);
var filteredList = filter is null
? new List<TaskEntity>()
: await baseQuery.Where(filter.MatchExpression).ToListAsync(ct);
ct.ThrowIfCancellationRequested();
var filter = _filters.Resolve(list.Id);
var filteredList = filter is null
? new List<TaskEntity>()
: all.Where(t => filter.Matches(t) || filter.MatchesAsContext(t, all)).ToList();
var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet();
var existingIds = filteredList.Select(t => t.Id).ToHashSet();
foreach (var c in all.Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId!)))
if (filter is not null)
{
if (existingIds.Add(c.Id))
filteredList.Add(c);
// Contextual parent rows (e.g. a planning parent hosting an already-matched queued/running
// child): only fetch candidates that could plausibly qualify — parents of tasks we already
// matched — instead of scanning the whole table.
var candidateParentIds = filteredList
.Where(t => t.ParentTaskId != null)
.Select(t => t.ParentTaskId!)
.Distinct()
.ToList();
if (candidateParentIds.Count > 0)
{
var existingParentIds = filteredList.Select(t => t.Id).ToHashSet();
var parentCandidates = await baseQuery
.Where(t => candidateParentIds.Contains(t.Id) && !existingParentIds.Contains(t.Id))
.ToListAsync(ct);
foreach (var p in parentCandidates)
if (filter.MatchesAsContext(p, filteredList))
filteredList.Add(p);
}
}
ct.ThrowIfCancellationRequested();
// Pull in every child of an already-matched top-level row, regardless of whether the child
// itself matches the filter, so subtasks render nested under their parent.
var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet();
if (topIds.Count > 0)
{
var existingIds = filteredList.Select(t => t.Id).ToHashSet();
var extraChildren = await baseQuery
.Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId!) && !existingIds.Contains(t.Id))
.ToListAsync(ct);
filteredList.AddRange(extraChildren);
}
filteredList = filteredList.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt).ToList();
var showListChip = list.Kind == ListKind.Virtual;
foreach (var t in filteredList)
{
+12 -60
View File
@@ -7,15 +7,16 @@
x:DataType="vm:TaskRowViewModel">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="6"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Above-row indicator: lives in the 6px gap between cards -->
<Border Grid.Row="0" Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintAbove}"/>
<!-- Above-row indicator: dashed placeholder gap showing where the dragged row will land -->
<Grid Grid.Row="0" Height="52" IsVisible="{Binding DropHintAbove}">
<Rectangle Margin="4,3" Stroke="{DynamicResource MossBrush}" StrokeThickness="1.5"
StrokeDashArray="4,3" Fill="Transparent" RadiusX="8" RadiusY="8"/>
</Grid>
<!-- Indent wrapper: col 0 = 24px child indent track, col 1 = content -->
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
@@ -31,58 +32,9 @@
Margin="0"
Classes.selected="{Binding IsSelected}"
Classes.dragging="{Binding IsDragging}"
Classes.done="{Binding Done}">
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="{loc:Tr tasks.ctxSendToQueue}"
IsVisible="{Binding CanSendToQueue}"
Click="OnSendToQueueClick"/>
<MenuItem Header="{loc:Tr tasks.ctxRemoveFromQueue}"
IsVisible="{Binding CanRemoveFromQueue}"
Click="OnRemoveFromQueueClick"/>
<MenuItem Header="{loc:Tr tasks.ctxCancelExecution}"
IsVisible="{Binding IsRunning}"
Click="OnCancelExecutionClick"/>
<Separator/>
<MenuItem Header="{loc:Tr tasks.ctxMarkAs}">
<MenuItem Header="{loc:Tr tasks.ctxMarkDone}" Tag="Done" Click="OnSetStatusClick"/>
<MenuItem Header="{loc:Tr tasks.ctxMarkCancelled}" Tag="Cancelled" Click="OnSetStatusClick"/>
<Separator/>
<MenuItem Header="{loc:Tr tasks.ctxMarkManual}"
IsVisible="{Binding !IsManual}"
Click="OnToggleManualClick"/>
<MenuItem Header="{loc:Tr tasks.ctxMarkClaudeTask}"
IsVisible="{Binding IsManual}"
Click="OnToggleManualClick"/>
</MenuItem>
<Separator/>
<MenuItem Header="{loc:Tr tasks.ctxOpenConPtySession}"
Click="OnOpenConPtySessionClick"/>
<MenuItem Header="{loc:Tr tasks.ctxOpenPlanningSession}"
Click="OnOpenPlanningSessionClick"
IsVisible="{Binding CanOpenPlanningSession}"/>
<MenuItem Header="{loc:Tr tasks.ctxResumePlanningSession}"
Click="OnResumePlanningSessionClick"
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
<MenuItem Header="{loc:Tr tasks.ctxFinalizePlanningSession}"
Click="OnFinalizePlanningSessionClick"
IsVisible="{Binding CanFinalizePlanning}"/>
<MenuItem Header="{loc:Tr tasks.ctxDiscardPlanningSession}"
Click="OnDiscardPlanningSessionClick"
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
<Separator/>
<MenuItem Header="{loc:Tr tasks.ctxScheduleFor}" Click="OnScheduleForClick"/>
<MenuItem Header="{loc:Tr tasks.ctxClearSchedule}"
IsVisible="{Binding HasSchedule}"
Click="OnClearScheduleClick"/>
<MenuItem Header="{loc:Tr tasks.ctxAddToMyDay}"
IsVisible="{Binding CanAddToMyDay}"
Click="OnAddToMyDayClick"/>
<MenuItem Header="{loc:Tr tasks.ctxRemoveFromMyDay}"
IsVisible="{Binding IsMyDay}"
Click="OnRemoveFromMyDayClick"/>
</ContextMenu>
</Border.ContextMenu>
Classes.drop-target="{Binding IsDropTarget}"
Classes.done="{Binding Done}"
ContextRequested="OnRowContextRequested">
<Grid ColumnDefinitions="0,18,32,*,Auto,Auto,32" Margin="6,8,10,8">
<!-- Chevron toggle (only for planning parent tasks) -->
@@ -252,9 +204,9 @@
</Grid>
<!-- Below-row indicator: only expands when visible (used for the last row of a section) -->
<Grid Grid.Row="2" Height="6" IsVisible="{Binding DropHintBelow}">
<Border Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"/>
<Grid Grid.Row="2" Height="52" IsVisible="{Binding DropHintBelow}">
<Rectangle Margin="4,3" Stroke="{DynamicResource MossBrush}" StrokeThickness="1.5"
StrokeDashArray="4,3" Fill="Transparent" RadiusX="8" RadiusY="8"/>
</Grid>
<!-- Hidden schedule anchor (its Flyout is shown from the context menu) -->
@@ -1,9 +1,11 @@
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;
@@ -19,6 +21,57 @@ 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.
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.SelectedTask = row;
var menu = new ContextMenu { DataContext = row };
MenuItem MakeItem(string key, EventHandler<RoutedEventArgs> click, bool isVisible = true, string? tag = null)
{
var item = new MenuItem { Header = Loc.T(key), DataContext = row, IsVisible = isVisible };
if (tag is not null) item.Tag = tag;
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));
menu.Items.Add(new Separator());
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"));
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);
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));
menu.Open(border);
e.Handled = true;
}
private async void OnSendToQueueClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)