feat(ui): list reordering, quick actions, and resizable modals

- Drag-to-reorder user lists in the sidebar, persisted via a new
  list sort_order column (AddListSortOrder migration, backfilled by
  creation time) and ListRepository.ReorderAsync
- "Open in Explorer" / "Open in Terminal" context-menu actions on lists
- "Clear all completed" button on the Tasks island
- Inline-edit subtask titles (empty text deletes the step) and
  click-to-copy task ID in the Details island
- Make modal and planning windows resizable (BorderOnly decorations
  with min sizes) instead of fixed-size borderless

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mika Kuns
2026-06-01 15:28:17 +02:00
parent 4148dcdb18
commit ab44ba5e41
24 changed files with 1093 additions and 27 deletions

View File

@@ -16,6 +16,9 @@ public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
builder.Property(l => l.CreatedAt).HasColumnName("created_at").IsRequired();
builder.Property(l => l.WorkingDir).HasColumnName("working_dir");
builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore");
builder.Property(l => l.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort");
builder.HasOne(l => l.Config)
.WithOne(c => c.List)

View File

@@ -0,0 +1,600 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260601114247_AddListSortOrder")]
partial class AddListSortOrder
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.Property<bool>("WorkdaysOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("workdays_only");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddListSortOrder : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "sort_order",
table: "lists",
type: "INTEGER",
nullable: false,
defaultValue: 0);
// Backfill existing rows with a dense order (0..N-1) by creation time
// so today's sidebar order is preserved after the migration.
migrationBuilder.Sql("""
WITH ordered AS (
SELECT id, (row_number() OVER (ORDER BY created_at) - 1) AS rn
FROM lists
)
UPDATE lists SET sort_order = (SELECT rn FROM ordered WHERE ordered.id = lists.id);
""");
migrationBuilder.CreateIndex(
name: "idx_lists_sort",
table: "lists",
column: "sort_order");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "idx_lists_sort",
table: "lists");
migrationBuilder.DropColumn(
name: "sort_order",
table: "lists");
}
}
}

View File

@@ -140,12 +140,21 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});

View File

@@ -7,6 +7,7 @@ public sealed class ListEntity
public required DateTime CreatedAt { get; init; }
public string? WorkingDir { get; set; }
public string DefaultCommitType { get; set; } = CommitTypeRegistry.DefaultType;
public int SortOrder { get; set; }
// Navigation properties
public ListConfigEntity? Config { get; set; }

View File

@@ -33,7 +33,19 @@ public sealed class ListRepository
public async Task<List<ListEntity>> GetAllAsync(CancellationToken ct = default)
{
return await _context.Lists.OrderBy(l => l.CreatedAt).ToListAsync(ct);
return await _context.Lists.OrderBy(l => l.SortOrder).ThenBy(l => l.CreatedAt).ToListAsync(ct);
}
public async Task ReorderAsync(IReadOnlyList<string> orderedListIds, CancellationToken ct = default)
{
var idSet = orderedListIds.ToHashSet();
var entities = await _context.Lists.Where(l => idSet.Contains(l.Id)).ToListAsync(ct);
for (int i = 0; i < orderedListIds.Count; i++)
{
var e = entities.FirstOrDefault(x => x.Id == orderedListIds[i]);
if (e is not null) e.SortOrder = i;
}
await _context.SaveChangesAsync(ct);
}
public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default)

View File

@@ -826,6 +826,15 @@
<Setter Property="Opacity" Value="0.5" />
<Setter Property="TextDecorations" Value="Strikethrough" />
</Style>
<Style Selector="TextBox.subtask-edit">
<Setter Property="Padding" Value="4,2" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
</Style>
<!-- ============================================================ -->
<!-- SECTION LABELS (OVERDUE / TASKS / COMPLETED) -->

View File

@@ -840,6 +840,35 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
CloseDetail?.Invoke();
}
[RelayCommand]
private async System.Threading.Tasks.Task CommitSubtaskEditAsync(SubtaskRowViewModel? row)
{
if (row is null || !row.IsEditing) return;
row.IsEditing = false;
var title = row.Title?.Trim() ?? "";
await using var ctx = _dbFactory.CreateDbContext();
var repo = new SubtaskRepository(ctx);
// Emptying the text removes the step.
if (string.IsNullOrEmpty(title))
{
await repo.DeleteAsync(row.Id);
Subtasks.Remove(row);
return;
}
var subs = await repo.GetByTaskIdAsync(Task?.Id ?? "");
var entity = subs.FirstOrDefault(s => s.Id == row.Id);
if (entity is null) return;
if (entity.Title != title)
{
entity.Title = title;
await repo.UpdateAsync(entity);
}
row.Title = title;
}
[RelayCommand]
private async System.Threading.Tasks.Task AddSubtaskAsync()
{
@@ -943,6 +972,7 @@ public sealed partial class SubtaskRowViewModel : ViewModelBase
public required string Id { get; init; }
[ObservableProperty] private string _title = "";
[ObservableProperty] private bool _done;
[ObservableProperty] private bool _isEditing;
[ObservableProperty] private ClaudeDo.Data.Models.TaskStatus _status;
[ObservableProperty] private ClaudeDo.Data.Models.WorktreeState _worktreeState = ClaudeDo.Data.Models.WorktreeState.Active;
}

View File

@@ -12,6 +12,8 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
[ObservableProperty] private bool _isActive;
[ObservableProperty] private string? _workingDir;
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
[ObservableProperty] private bool _dropHintAbove;
[ObservableProperty] private bool _dropHintBelow;
public string? IconKey { get; init; }
public string? DotColorKey { get; init; }
}

View File

@@ -82,6 +82,52 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
finally { _worktreesOverviewOpen = false; }
}
[RelayCommand]
private void OpenInExplorer(ListNavItemViewModel? row)
{
var dir = row?.WorkingDir;
if (string.IsNullOrWhiteSpace(dir) || !System.IO.Directory.Exists(dir)) return;
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = dir,
UseShellExecute = true,
});
}
catch { /* best-effort */ }
}
[RelayCommand]
private void OpenInTerminal(ListNavItemViewModel? row)
{
var dir = row?.WorkingDir;
if (string.IsNullOrWhiteSpace(dir) || !System.IO.Directory.Exists(dir)) return;
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "wt.exe",
Arguments = $"-d \"{dir}\"",
UseShellExecute = true,
});
}
catch
{
// Windows Terminal not installed — fall back to a plain console at the directory.
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "cmd.exe",
WorkingDirectory = dir,
UseShellExecute = true,
});
}
catch { /* best-effort */ }
}
}
public ObservableCollection<ListNavItemViewModel> Items { get; } = new();
public ObservableCollection<ListNavItemViewModel> SmartLists { get; } = new();
public ObservableCollection<ListNavItemViewModel> UserLists { get; } = new();
@@ -231,6 +277,57 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
}
}
public void ClearDropHints()
{
foreach (var r in UserLists)
{
r.DropHintAbove = false;
r.DropHintBelow = false;
}
}
public void SetDropHint(ListNavItemViewModel target, bool placeBelow)
{
foreach (var r in UserLists)
{
var isTarget = ReferenceEquals(r, target);
r.DropHintAbove = isTarget && !placeBelow;
r.DropHintBelow = isTarget && placeBelow;
}
}
public async Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow)
{
if (source.Kind != ListKind.User || target.Kind != ListKind.User) return;
if (ReferenceEquals(source, target)) return;
MoveWithinCollection(UserLists, source, target, placeBelow);
var orderedIds = UserLists.Select(i => i.Id["user:".Length..]).ToList();
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = new ListRepository(ctx);
await lists.ReorderAsync(orderedIds);
}
private static void MoveWithinCollection(
ObservableCollection<ListNavItemViewModel> coll,
ListNavItemViewModel source,
ListNavItemViewModel target,
bool placeBelow)
{
var srcIdx = coll.IndexOf(source);
var tgtIdx = coll.IndexOf(target);
if (srcIdx < 0 || tgtIdx < 0 || srcIdx == tgtIdx) return;
var finalIdx = placeBelow ? tgtIdx + 1 : tgtIdx;
if (srcIdx < finalIdx) finalIdx--;
if (finalIdx < 0) finalIdx = 0;
if (finalIdx >= coll.Count) finalIdx = coll.Count - 1;
if (finalIdx == srcIdx) return;
coll.Move(srcIdx, finalIdx);
}
partial void OnSelectedListChanged(ListNavItemViewModel? value)
{
foreach (var i in Items) i.IsActive = ReferenceEquals(i, value);

View File

@@ -487,6 +487,38 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
private async Task ClearCompletedAsync()
{
if (CompletedItems.Count == 0) return;
// Delete children before parents so the parent-child FK (Restrict) doesn't
// block removing a completed planning parent together with its done children.
var toDelete = CompletedItems.OrderByDescending(r => r.IsChild).ToList();
if (ConfirmAsync is not null)
{
var ok = await ConfirmAsync($"Clear {toDelete.Count} completed task(s)? This cannot be undone.");
if (!ok) return;
}
await using var db = await _dbFactory.CreateDbContextAsync();
var repo = new TaskRepository(db);
foreach (var row in toDelete)
{
try
{
await repo.DeleteAsync(row.Id);
Items.Remove(row);
}
catch { /* still referenced by open child tasks; leave it visible */ }
}
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
private async Task ToggleStarAsync(TaskRowViewModel row)
{

View File

@@ -50,7 +50,10 @@
<StackPanel Grid.Column="1" Spacing="0">
<TextBlock Classes="meta"
Text="{Binding TaskIdBadge}"
Margin="0,0,0,4"/>
Margin="0,0,0,4"
Cursor="Hand"
ToolTip.Tip="Copy task ID"
Tapped="OnTaskIdTapped"/>
<TextBox Text="{Binding EditableTitle, Mode=TwoWay}"
FontSize="{StaticResource FontSizeTaskTitle}" FontWeight="Medium"
BorderThickness="0" Background="Transparent"
@@ -186,13 +189,30 @@
Width="16" Height="16"
Cursor="Hand"/>
</Button>
<TextBlock Grid.Column="1"
Classes="subtask-title"
<Panel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Classes="subtask-title"
Text="{Binding Title}"
IsVisible="{Binding !IsEditing}"
FontSize="{StaticResource FontSizeBody}"
Foreground="{DynamicResource TextDimBrush}"
VerticalAlignment="Center"
TextWrapping="Wrap"/>
TextWrapping="Wrap"
Cursor="Ibeam"
Tapped="OnSubtaskTitleTapped"/>
<TextBox Classes="subtask-edit"
Text="{Binding Title, Mode=TwoWay}"
IsVisible="{Binding IsEditing}"
FontSize="{StaticResource FontSizeBody}"
AcceptsReturn="False"
TextWrapping="Wrap"
LostFocus="OnSubtaskEditLostFocus">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter"
Command="{Binding $parent[ItemsControl].((vm:DetailsIslandViewModel)DataContext).CommitSubtaskEditCommand}"
CommandParameter="{Binding}"/>
</TextBox.KeyBindings>
</TextBox>
</Panel>
</Grid>
</Border>
</DataTemplate>

View File

@@ -1,9 +1,13 @@
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.Views.Modals;
using ClaudeDo.Ui.Views.Planning;
@@ -135,6 +139,31 @@ public partial class DetailsIslandView : UserControl
return await tcs.Task;
}
private void OnSubtaskTitleTapped(object? sender, TappedEventArgs e)
{
if (sender is not Control c || c.DataContext is not SubtaskRowViewModel row) return;
row.IsEditing = true;
var box = (c.GetVisualParent() as Panel)?.GetVisualDescendants().OfType<TextBox>().FirstOrDefault();
if (box is not null)
Dispatcher.UIThread.Post(() => { box.Focus(); box.SelectAll(); }, DispatcherPriority.Background);
}
private void OnSubtaskEditLostFocus(object? sender, RoutedEventArgs e)
{
if (DataContext is DetailsIslandViewModel vm
&& sender is Control c && c.DataContext is SubtaskRowViewModel row)
vm.CommitSubtaskEditCommand.Execute(row);
}
private async void OnTaskIdTapped(object? sender, TappedEventArgs e)
{
if (DataContext is not DetailsIslandViewModel vm || vm.Task is null) return;
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard is null) return;
await clipboard.SetTextAsync(vm.Task.Id);
}
private async void OnCopyDescriptionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not DetailsIslandViewModel vm) return;

View File

@@ -113,8 +113,18 @@
<ItemsControl ItemsSource="{Binding UserLists}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:ListNavItemViewModel">
<Border Classes="list-item" Classes.active="{Binding IsActive}"
Tapped="OnItemTapped">
<Grid RowDefinitions="Auto,Auto,Auto">
<!-- Above-row drop indicator -->
<Border Grid.Row="0" Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintAbove}"/>
<Border Grid.Row="1" Classes="list-item" Classes.active="{Binding IsActive}"
Tapped="OnItemTapped"
DragDrop.AllowDrop="True"
DragDrop.DragOver="OnListDragOver"
DragDrop.Drop="OnListDrop">
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="Settings..."
@@ -123,6 +133,15 @@
<MenuItem Header="Worktrees…"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenWorktreesOverviewCommand}"
CommandParameter="{Binding}"/>
<Separator IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<MenuItem Header="Open in Explorer"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInExplorerCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="Open in Terminal"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInTerminalCommand}"
CommandParameter="{Binding}"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="20,*,Auto">
@@ -152,6 +171,12 @@
Text="{Binding Count}"/>
</Grid>
</Border>
<!-- Below-row drop indicator (last item only) -->
<Border Grid.Row="2" Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintBelow}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>

View File

@@ -1,9 +1,11 @@
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals;
@@ -13,9 +15,13 @@ namespace ClaudeDo.Ui.Views.Islands;
public partial class ListsIslandView : UserControl
{
private static readonly DataFormat<string> ListRowFormat =
DataFormat.CreateStringApplicationFormat("claudedo-list-row");
public ListsIslandView()
{
InitializeComponent();
AddHandler(PointerPressedEvent, OnTunnelPointerPressed, RoutingStrategies.Tunnel);
DataContextChanged += (_, _) =>
{
if (DataContext is ListsIslandViewModel vm)
@@ -84,6 +90,127 @@ public partial class ListsIslandView : UserControl
vm.SelectCommand.Execute(item);
}
private async void OnTunnelPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (DataContext is not ListsIslandViewModel vm) return;
if (e.Source is not Visual src) return;
var border = FindListItemBorder(src);
if (border?.DataContext is not ListNavItemViewModel row || row.Kind != ListKind.User) return;
if (!e.GetCurrentPoint(border).Properties.IsLeftButtonPressed) return;
// Double-click opens the list's settings instead of starting a drag. Handled here
// because DoDragDropAsync captures the pointer and would swallow a DoubleTapped event.
if (e.ClickCount == 2)
{
vm.OpenListSettingsCommand.Execute(row);
return;
}
// Select now so the right pane updates whether the gesture becomes a click or a drag
// (the Tapped handler doesn't fire once DoDragDropAsync captures the pointer).
vm.SelectCommand.Execute(row);
var data = new DataTransfer();
data.Add(DataTransferItem.Create(ListRowFormat, row.Id));
try
{
await DragDrop.DoDragDropAsync(e, data, DragDropEffects.Move);
}
finally
{
vm.ClearDropHints();
}
}
private void OnListDragOver(object? sender, DragEventArgs e)
{
if (DataContext is not ListsIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; }
if (!e.DataTransfer?.Contains(ListRowFormat) ?? true)
{
e.DragEffects = DragDropEffects.None;
vm.ClearDropHints();
return;
}
if (sender is not Border b || b.DataContext is not ListNavItemViewModel target || target.Kind != ListKind.User)
{
e.DragEffects = DragDropEffects.None;
vm.ClearDropHints();
return;
}
var sourceId = e.DataTransfer?.TryGetValue(ListRowFormat);
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". Only the last row shows a below-line.
ListNavItemViewModel hintRow = target;
bool hintBelow = false;
if (placeBelow)
{
var next = FindNextUserList(vm, target);
if (next is not null) { hintRow = next; hintBelow = false; }
else { hintRow = target; hintBelow = true; }
}
if (hintRow.Id == sourceId)
{
e.DragEffects = DragDropEffects.None;
vm.ClearDropHints();
return;
}
vm.SetDropHint(hintRow, hintBelow);
e.DragEffects = DragDropEffects.Move;
}
private async void OnListDrop(object? sender, DragEventArgs e)
{
if (DataContext is not ListsIslandViewModel vm) return;
try
{
if (sender is not Border b || b.DataContext is not ListNavItemViewModel target || target.Kind != ListKind.User) return;
var sourceId = e.DataTransfer?.TryGetValue(ListRowFormat);
if (string.IsNullOrEmpty(sourceId) || sourceId == target.Id) return;
var source = vm.UserLists.FirstOrDefault(r => r.Id == sourceId);
if (source is null) return;
var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2;
vm.ClearDropHints();
await vm.ReorderAsync(source, target, placeBelow);
}
catch
{
vm.ClearDropHints();
throw;
}
}
private static Border? FindListItemBorder(Visual? v)
{
while (v is not null)
{
if (v is Border b && b.Classes.Contains("list-item")) return b;
v = v.GetVisualParent();
}
return null;
}
private static ListNavItemViewModel? FindNextUserList(ListsIslandViewModel vm, ListNavItemViewModel row)
{
var idx = vm.UserLists.IndexOf(row);
if (idx < 0) return null;
return idx + 1 < vm.UserLists.Count ? vm.UserLists[idx + 1] : null;
}
private async System.Threading.Tasks.Task ShowSettingsAsync(SettingsModalViewModel settingsVm)
{
var owner = TopLevel.GetTopLevel(this) as Window;

View File

@@ -126,8 +126,17 @@
<Binding Path="IsShowingCompleted"/>
</MultiBinding>
</StackPanel.IsVisible>
<TextBlock Classes="eyebrow section-label"
Text="{Binding CompletedHeader}" Margin="14,14,14,6"/>
<Grid ColumnDefinitions="*,Auto" Margin="14,14,14,6">
<TextBlock Grid.Column="0" Classes="eyebrow section-label"
Text="{Binding CompletedHeader}" VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="icon-btn"
Command="{Binding ClearCompletedCommand}"
ToolTip.Tip="Clear all completed"
VerticalAlignment="Center">
<PathIcon Data="{StaticResource Icon.Trash}" Width="13" Height="13"
Foreground="{DynamicResource BloodBrush}"/>
</Button>
</Grid>
<ItemsControl ItemsSource="{Binding CompletedItems}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:TaskRowViewModel">

View File

@@ -5,9 +5,11 @@
x:Class="ClaudeDo.Ui.Views.Modals.DiffModalView"
x:DataType="vm:DiffModalViewModel"
Title="Diff"
Width="1200" Height="800"
WindowDecorations="None"
Width="1200" Height="800" MinWidth="700" MinHeight="450"
CanResize="True"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">

View File

@@ -8,8 +8,9 @@
Width="520" Height="720"
CanResize="True"
MinWidth="460" MinHeight="520"
WindowDecorations="None"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">

View File

@@ -5,10 +5,11 @@
x:Class="ClaudeDo.Ui.Views.Modals.MergeModalView"
x:DataType="vm:MergeModalViewModel"
Title="Merge worktree"
Width="560" Height="460"
CanResize="False"
WindowDecorations="None"
Width="560" Height="460" MinWidth="460" MinHeight="360"
CanResize="True"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">

View File

@@ -5,9 +5,11 @@
x:Class="ClaudeDo.Ui.Views.Modals.RepoImportModalView"
x:DataType="vm:RepoImportModalViewModel"
Title="Add repos as lists"
Width="560" Height="480"
WindowDecorations="None"
Width="560" Height="480" MinWidth="420" MinHeight="320"
CanResize="True"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<Window.KeyBindings>

View File

@@ -7,9 +7,11 @@
x:Class="ClaudeDo.Ui.Views.Modals.SettingsModalView"
x:DataType="vm:SettingsModalViewModel"
Title="Settings"
Width="580" Height="760"
WindowDecorations="None"
Width="580" Height="760" MinWidth="480" MinHeight="520"
CanResize="True"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">

View File

@@ -11,7 +11,8 @@
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True">
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1">
<Window.Resources>
<converters:WorktreeStateColorConverter x:Key="WorktreeStateColor"/>

View File

@@ -5,9 +5,11 @@
x:DataType="vm:ConflictResolutionViewModel"
x:Class="ClaudeDo.Ui.Views.Planning.ConflictResolutionView"
Title="Merge conflict"
Width="560" SizeToContent="Height"
WindowDecorations="None"
Width="560" SizeToContent="Height" MinWidth="460"
CanResize="True"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">

View File

@@ -5,9 +5,11 @@
x:Class="ClaudeDo.Ui.Views.Planning.PlanningDiffView"
x:DataType="vm:PlanningDiffViewModel"
Title="Planning — Combined diff"
Width="1100" Height="700"
WindowDecorations="None"
Width="1100" Height="700" MinWidth="700" MinHeight="450"
CanResize="True"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">