21 Commits

Author SHA1 Message Date
Mika Kuns
a4e313dbad improve Frontend 2026-04-22 17:09:00 +02:00
Mika Kuns
7de5510735 fix(ui): session terminal scrolls to end after layout so last line is fully visible 2026-04-22 15:42:02 +02:00
Mika Kuns
5e54275842 fix(ui): pin AgentStrip above metadata footer, terminal sits above it 2026-04-22 15:38:34 +02:00
Mika Kuns
6ac88235a7 fix(ui): session terminal auto-sizes to output, caps at 420px before scrolling 2026-04-22 15:34:47 +02:00
Mika Kuns
c599fdcb8c refactor(ui): single scrollable DetailsIsland body with agent-settings gear flyout, remove Notes 2026-04-22 15:16:40 +02:00
Mika Kuns
b0b15e474e feat(ui): always-visible Steps section at top of DetailsIsland with add-step input 2026-04-22 15:08:07 +02:00
Mika Kuns
839f862b7d fix(ui): move agent-settings expander out of capped scroller so it expands properly 2026-04-22 13:50:35 +02:00
Mika Kuns
2901a769d8 fix(ui): use PlaceholderText instead of obsolete Watermark in ListSettingsModalView 2026-04-22 13:33:49 +02:00
Mika Kuns
e74e7eecf4 docs: refresh CLAUDE.md files for agent settings UI 2026-04-22 13:31:28 +02:00
Mika Kuns
bba577888b feat(ui): per-task agent settings in DetailsIsland
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 13:29:57 +02:00
Mika Kuns
5784dbee94 feat(ui): open ListSettingsModal via context menu and gear button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 13:27:05 +02:00
Mika Kuns
5348220e60 feat(ui): add ListSettingsModalView 2026-04-22 13:22:39 +02:00
Mika Kuns
cd0b95ef9a feat(ui): add ListSettingsModalViewModel 2026-04-22 13:20:42 +02:00
Mika Kuns
fc1cfe59ec feat(ui): WorkerClient supports list/task agent settings + ListUpdated event 2026-04-22 13:18:16 +02:00
Mika Kuns
7c312161bb feat(worker): add hub methods for list and task agent settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 13:16:46 +02:00
Mika Kuns
480eb0817a feat(data): add TaskRepository.UpdateAgentSettingsAsync 2026-04-22 13:10:21 +02:00
Mika Kuns
1b94fa5c44 feat(data): add ListRepository.DeleteConfigAsync 2026-04-22 13:09:03 +02:00
Mika Kuns
02464b7f89 docs(plans): agent settings UI implementation plan 2026-04-22 12:06:31 +02:00
Mika Kuns
68f461d0e1 docs(specs): agent settings per list and per task UI reimplementation 2026-04-22 12:01:20 +02:00
Mika Kuns
cfb410dd4d Merge task: Improve the Readme 2026-04-22 11:34:13 +02:00
Mika Kuns
883c98dc0a chore(claude-do): Improve the Readme
ClaudeDo-Task: 2d1915154c1448118a7e0158c13de113
2026-04-21 15:31:26 +02:00
37 changed files with 2670 additions and 104 deletions

View File

@@ -16,29 +16,29 @@ Two-process system communicating over SignalR:
| **ClaudeDo.Worker** | ASP.NET Core hosted service, task queue, Claude CLI runner | | **ClaudeDo.Worker** | ASP.NET Core hosted service, task queue, Claude CLI runner |
``` ```
┌──────────────┐ SignalR ──────────────┐ ┌────────────────┐ SignalR ┌────────────────┐
│ ClaudeDo.App│◄──────────►│ClaudeDo.Worker│ │ ClaudeDo.App │◄──────────►│ ClaudeDo.Worker
│ (Avalonia) │ 127.0.0.1 │ (ASP.NET) │ (Avalonia) │ 127.0.0.1 │ (ASP.NET Core)
│ │ :47821 │ │ │ │ :47821 │ │
│ ┌──────────┐│ │ ┌──────────┐ │ │ ┌────────────┐│ │ ┌────────────┐ │
│ │ Ui ││ │ │ TaskQueue │ │ │ │ Ui ││ │ │ TaskQueue │ │
│ │(ViewModels)│ │ │ Claude CLI│ │ │ │(ViewModels)│ │ │ Claude CLI │ │
│ └──────────┘│ │ └──────────┘ │ │ └────────────┘│ │ └────────────┘ │
└─────────────┘ ──────┬───────┘ └───────┬────────┘ └───────┬───────
│ │ │ │
───────────┬───────────────┘ └──────────────┬───────────────┘
──────┴──────┐ ┌───────┴──────
│ ClaudeDo.Data │ │ ClaudeDo.Data │
│ (SQLite) │ │ (SQLite) │
─────────────┘ └───────────────┘
``` ```
## Tech Stack ## Tech Stack
- .NET 8.0 - .NET 8.0
- Avalonia 12.0.0 (Fluent theme) - Avalonia 12.0.0 (Fluent theme)
- SQLite (WAL mode) via Microsoft.Data.Sqlite — raw ADO.NET, no ORM - SQLite (WAL mode) via Entity Framework Core (EF Core + Migrations)
- SignalR for real-time IPC between UI and Worker - SignalR for real-time IPC between UI and Worker
- CommunityToolkit.Mvvm for source-generated MVVM - CommunityToolkit.Mvvm for source-generated MVVM
- Git worktrees for task isolation - Git worktrees for task isolation
@@ -53,7 +53,8 @@ Two-process system communicating over SignalR:
```bash ```bash
# Build # Build
dotnet build ClaudeDo.slnx dotnet build src/ClaudeDo.App
dotnet build src/ClaudeDo.Worker
# Run tests # Run tests
dotnet test tests/ClaudeDo.Worker.Tests dotnet test tests/ClaudeDo.Worker.Tests

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,162 @@
# Design: Agent settings per list and per task (UI reimplementation)
Date: 2026-04-22
Status: Approved by user, implementation pending
## Problem
During the recent UI rework, the editors for per-list and per-task agent settings were lost. The data layer and Worker still support them (`TaskEntity.Model/SystemPrompt/AgentPath`, `ListConfigEntity`, `TaskRunner` + `ClaudeArgsBuilder`), but the UI has zero references to these fields. Users currently cannot set model, custom system prompt, or agent file from the app.
## Goal
Restore the ability to configure, per **list** and per **task**:
- `Model``opus` / `sonnet` / `haiku` / inherit
- `SystemPrompt` — free-text, appended to Claude's system prompt
- `AgentPath` — selection from agent files discovered by the Worker under `~/.todo-app/agents/*.md`
Per-task values override per-list values. Per-list values override global defaults from `worker.config.json`. This cascade is already implemented in `TaskRunner`.
## Non-goals
- Agent file CRUD in the UI (read-only picker only)
- `--allowedTools`, `--bare`, permission modes (deferred, matches existing worker design notes)
- Any schema migration — the DB already has the required columns/tables
## Approach
**Update-and-broadcast over SignalR.** UI never touches the DB directly; all writes go through new `WorkerHub` methods. Worker persists via repositories, then broadcasts `ListUpdated` / `TaskUpdated` so connected clients refresh.
## Sections
### 1. Data layer additions
New repository `src/ClaudeDo.Data/Repositories/ListConfigRepository.cs`:
- `GetByListIdAsync(string listId, CancellationToken) -> ListConfigEntity?`
- `UpsertAsync(ListConfigEntity, CancellationToken) -> ListConfigEntity`
- `DeleteAsync(string listId, CancellationToken) -> bool`
New method on `ListRepository`:
- `UpdateAsync(ListEntity, CancellationToken)` — updates `Name`, `WorkingDir`, `DefaultCommitType`. Included because the consolidated list-settings modal edits these alongside agent fields.
New method on `TaskRepository`:
- `UpdateAgentSettingsAsync(string taskId, string? model, string? systemPrompt, string? agentPath, CancellationToken) -> bool`
- `null` values mean "inherit" (column is nulled out in DB).
- Kept as a narrow method to avoid widening `UpdateAsync`.
DI: register `ListConfigRepository` alongside other repos.
No migration — all columns/tables already exist.
### 2. SignalR hub surface
New DTOs in `src/ClaudeDo.Data/Dtos/` (project existing DTO pattern):
- `UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType)`
- `UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath)`
- `UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath)`
New methods on `WorkerHub`:
- `UpdateList(UpdateListDto dto)` — calls `ListRepository.UpdateAsync`, then broadcasts `ListUpdated(listId)`.
- `UpdateListConfig(UpdateListConfigDto dto)` — upserts via `ListConfigRepository.UpsertAsync`, broadcasts `ListUpdated(listId)`. If all three fields are null, calls `DeleteAsync` instead so the row doesn't linger empty.
- `UpdateTaskAgentSettings(UpdateTaskAgentSettingsDto dto)` — calls `TaskRepository.UpdateAgentSettingsAsync`, broadcasts `TaskUpdated(taskId)` (existing event).
New broadcast method on `HubBroadcaster`:
- `Task ListUpdatedAsync(string listId) => _hub.Clients.All.SendAsync("ListUpdated", listId);`
Loader endpoint to add:
- `GetListConfig(string listId)` — returns `(string? Model, string? SystemPrompt, string? AgentPath)` record, or `null` if no row. Used by `ListSettingsModal` and by `DetailsIslandViewModel` for effective-value inheritance display. Existing `GetLists` / `GetTasks` already cover the rest.
### 3. UI — ListSettingsModal
New files:
- `src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml` + `.axaml.cs`
- `src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs`
Entry points:
- **Right-click** on a list row in `ListsIslandView``ContextMenu` with "Settings…" item
- **Gear button** on the list row (visible on hover/selected)
Layout: vertical stack with two grouped sections.
**General**
- `Name` — TextBox (required, non-empty)
- `Working directory` — TextBox + "Browse…" button (folder picker)
- `Default commit type` — ComboBox populated with `chore, feat, fix, refactor, docs, test, ci, perf, style, build`
**Agent**
- `Model` — ComboBox: `(default)`, `sonnet`, `opus`, `haiku` (selecting `(default)` sends `null`)
- `System prompt` — multi-line TextBox with 4-row min height; empty = `null`
- `Agent file` — ComboBox populated via `WorkerClient.GetAgentsAsync()`, first item `(none)`; tooltip shows each agent's `Description`. Empty selection = `null`.
- `Reset agent settings` button — clears Model/SystemPrompt/AgentPath in the form (save then sends null triple → backend `DeleteAsync`).
Commands:
- `SaveCommand` — validates, calls `UpdateList` then `UpdateListConfig`, closes modal on success.
- `CancelCommand` — closes without saving.
Loading: on open, ViewModel calls `GetListConfig` and populates fields; missing row means all three agent fields start empty.
ViewModel uses `[ObservableProperty]` / `[RelayCommand]` per project convention.
### 4. UI — DetailsIsland per-task agent section
Modify `DetailsIslandView.axaml` + `DetailsIslandViewModel.cs`.
Add an `Expander` titled **"Agent settings (overrides)"**, collapsed by default, below the existing task detail content.
Fields (same control types as ListSettingsModal's Agent section):
- `Model` — ComboBox prepended with `(inherit: <effective>)` option as the unset state
- `System prompt` — TextBox with watermark showing effective inherited value when empty
- `Agent file` — ComboBox prepended with `(inherit: <effective>)`
Effective-value computation:
- Server-side would be more accurate but requires a new hub call. For v1, UI computes locally: if task field is null, show the list's config value; if that's also null, show `(global default)`.
- `DetailsIslandViewModel` already has access to the selected `TaskDto` + list; add list-config loading when task selection changes.
Persistence: auto-save on field change (debounced 300ms) calling `UpdateTaskAgentSettings`. No separate Save button — matches "settings" feel.
If the task is currently `Running`, fields are **read-only** (disabling controls). Agent settings only apply to the next invocation.
### 5. Testing
xUnit integration tests in `tests/ClaudeDo.Worker.Tests` against a real SQLite temp DB:
- `ListConfigRepositoryTests`
- `UpsertAsync_InsertsWhenAbsent`
- `UpsertAsync_UpdatesWhenPresent`
- `DeleteAsync_RemovesRow`
- `GetByListIdAsync_ReturnsNullWhenAbsent`
- `ListRepositoryTests.UpdateAsync_UpdatesMutableFields`
- `TaskRepositoryTests.UpdateAgentSettingsAsync_NullsClearColumns`
- `WorkerHubTests` (if present pattern; otherwise via direct service call):
- `UpdateListConfig_AllNull_DeletesRow`
- `UpdateTaskAgentSettings_PersistsAndBroadcasts`
No UI tests — project has no UI test project. Build-time compile check is the only UI gate.
## Manual verification checklist
1. Open app, right-click a list → "Settings…" opens modal with correct current values.
2. Change model to `opus`, save, reopen → model persists.
3. Set system prompt on list, create task in list, run it → log confirms `--append-system-prompt` was passed.
4. Select task, set per-task Model = `haiku`, run → log confirms `--model haiku` overrides list value.
5. Unset per-task Model → effective falls back to list's model.
6. Click "Reset agent settings" on list → row removed, tasks fall back to global defaults.
7. Running task: DetailsIsland agent fields disabled.
## Risks / open questions
- **Refresh propagation**: `ListUpdated` is a new event; `IslandsShellViewModel` must subscribe and re-fetch. Any missed subscriber means stale UI. Mitigated by following the existing `TaskUpdated` pattern exactly.
- **Working-dir browser**: Avalonia folder picker API needs a `TopLevel`; pass via `StorageProvider`. Standard pattern in Avalonia 12.
- **Conventional-commit-type list**: hardcoded in ComboBox — acceptable, matches existing `CommitType` defaults.

View File

@@ -79,12 +79,14 @@ sealed class Program
sc.AddTransient<WorktreeModalViewModel>(); sc.AddTransient<WorktreeModalViewModel>();
sc.AddTransient<SettingsModalViewModel>(); sc.AddTransient<SettingsModalViewModel>();
sc.AddTransient<MergeModalViewModel>(); sc.AddTransient<MergeModalViewModel>();
sc.AddTransient<ListSettingsModalViewModel>();
// Islands shell VMs // Islands shell VMs
sc.AddSingleton<ListsIslandViewModel>(sp => sc.AddSingleton<ListsIslandViewModel>(sp =>
new ListsIslandViewModel( new ListsIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(), sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp)); sp,
sp.GetRequiredService<WorkerClient>()));
sc.AddSingleton<TasksIslandViewModel>(sp => sc.AddSingleton<TasksIslandViewModel>(sp =>
new TasksIslandViewModel(sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>())); new TasksIslandViewModel(sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>()));
sc.AddSingleton<DetailsIslandViewModel>(sp => sc.AddSingleton<DetailsIslandViewModel>(sp =>

View File

@@ -4,19 +4,23 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
## Models ## Models
- **TaskEntity** — Id, ListId, Title, Description, Status (Manual|Queued|Running|Done|Failed), ScheduledFor, Result, LogPath, timestamps, CommitType - **TaskEntity** — Id, ListId, Title, Description, Status (Manual|Queued|Running|Done|Failed), ScheduledFor, Result, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath (nullable overrides), IsStarred, IsMyDay, Notes
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt - **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath (all nullable)
- **TagEntity** — Id (autoincrement), Name (unique) - **TagEntity** — Id (autoincrement), Name (unique)
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept) - **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept)
- **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path)
- **SubtaskEntity**, **AppSettingsEntity**, **AgentInfo** — existing helpers / settings / record for scanned agent files
## Repositories ## Repositories
All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. Exception: `TaskRepository.GetNextQueuedAgentTaskAsync` uses `FromSqlRaw` for atomic queue claim. All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. Exception: `TaskRepository.GetNextQueuedAgentTaskAsync` uses `FromSqlRaw` for atomic queue claim.
- **TaskRepository** — CRUD, status transitions (`MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`), `GetNextQueuedAgentTaskAsync` (queue polling), `GetEffectiveTagsAsync` (union of task + list tags), `FlipAllRunningToFailedAsync` - **TaskRepository** — CRUD, status transitions (`MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`), `GetNextQueuedAgentTaskAsync` (queue polling), `GetEffectiveTagsAsync` (union of task + list tags), `FlipAllRunningToFailedAsync`, `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides)
- **ListRepository** — CRUD, tag junction management - **ListRepository** — CRUD, tag junction management, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config`
- **TagRepository** — `GetOrCreateAsync` (idempotent) - **TagRepository** — `GetOrCreateAsync` (idempotent)
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync` - **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`
- **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
## Infrastructure ## Infrastructure
@@ -31,7 +35,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. Exception: `T
## Schema ## Schema
6 tables: `lists`, `tasks`, `tags`, `list_tags`, `task_tags`, `worktrees`. Managed by EF Core migrations in the `Migrations/` folder. Seed data: tags "agent" and "manual". Tables: `lists`, `tasks`, `tags`, `list_tags`, `task_tags`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`. Managed by EF Core migrations in the `Migrations/` folder. Seed data: tags "agent" and "manual".
## Conventions ## Conventions

View File

@@ -51,6 +51,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
builder.Property(t => t.IsStarred).HasColumnName("is_starred").HasDefaultValue(false); builder.Property(t => t.IsStarred).HasColumnName("is_starred").HasDefaultValue(false);
builder.Property(t => t.IsMyDay).HasColumnName("is_my_day").HasDefaultValue(false); builder.Property(t => t.IsMyDay).HasColumnName("is_my_day").HasDefaultValue(false);
builder.Property(t => t.Notes).HasColumnName("notes"); builder.Property(t => t.Notes).HasColumnName("notes");
builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
builder.HasOne(t => t.List) builder.HasOne(t => t.List)
.WithMany(l => l.Tasks) .WithMany(l => l.Tasks)
@@ -74,5 +75,6 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id"); builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id");
builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status"); builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status");
builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort");
} }
} }

View File

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

View File

@@ -281,6 +281,12 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("scheduled_for"); .HasColumnName("scheduled_for");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt") b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("started_at"); .HasColumnName("started_at");
@@ -307,6 +313,9 @@ namespace ClaudeDo.Data.Migrations
b.HasIndex("Status") b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status"); .HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null); b.ToTable("tasks", (string)null);
}); });

View File

@@ -29,6 +29,7 @@ public sealed class TaskEntity
public bool IsStarred { get; set; } public bool IsStarred { get; set; }
public bool IsMyDay { get; set; } public bool IsMyDay { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public int SortOrder { get; set; }
// Navigation properties // Navigation properties
public ListEntity List { get; set; } = null!; public ListEntity List { get; set; } = null!;

View File

@@ -88,4 +88,12 @@ public sealed class ListRepository
} }
await _context.SaveChangesAsync(ct); await _context.SaveChangesAsync(ct);
} }
public async Task<bool> DeleteConfigAsync(string listId, CancellationToken ct = default)
{
var affected = await _context.ListConfigs
.Where(c => c.ListId == listId)
.ExecuteDeleteAsync(ct);
return affected > 0;
}
} }

View File

@@ -14,6 +14,13 @@ public sealed class TaskRepository
public async Task AddAsync(TaskEntity entity, CancellationToken ct = default) public async Task AddAsync(TaskEntity entity, CancellationToken ct = default)
{ {
// Append at bottom of the list by default: SortOrder = max(listId) + 1.
var maxSort = await _context.Tasks
.Where(t => t.ListId == entity.ListId)
.Select(t => (int?)t.SortOrder)
.MaxAsync(ct);
entity.SortOrder = (maxSort ?? -1) + 1;
_context.Tasks.Add(entity); _context.Tasks.Add(entity);
await _context.SaveChangesAsync(ct); await _context.SaveChangesAsync(ct);
} }
@@ -38,10 +45,32 @@ public sealed class TaskRepository
{ {
return await _context.Tasks return await _context.Tasks
.Where(t => t.ListId == listId) .Where(t => t.ListId == listId)
.OrderBy(t => t.CreatedAt) .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.ToListAsync(ct); .ToListAsync(ct);
} }
/// <summary>
/// Renumbers tasks in a list to 0..N-1 according to <paramref name="orderedTaskIds"/>.
/// Ids not belonging to the list are ignored; ids missing from the list are untouched.
/// </summary>
public async Task ReorderAsync(string listId, IReadOnlyList<string> orderedTaskIds, CancellationToken ct = default)
{
if (orderedTaskIds.Count == 0) return;
var idSet = orderedTaskIds.ToHashSet();
var tasks = await _context.Tasks
.Where(t => t.ListId == listId && idSet.Contains(t.Id))
.ToListAsync(ct);
for (int i = 0; i < orderedTaskIds.Count; i++)
{
var task = tasks.FirstOrDefault(t => t.Id == orderedTaskIds[i]);
if (task is not null) task.SortOrder = i;
}
await _context.SaveChangesAsync(ct);
}
// Kept for backwards-compatibility with callers using the old name. // Kept for backwards-compatibility with callers using the old name.
public Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default) public Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default)
=> GetByListIdAsync(listId, ct); => GetByListIdAsync(listId, ct);
@@ -111,6 +140,25 @@ public sealed class TaskRepository
#endregion #endregion
#region Agent settings
public async Task UpdateAgentSettingsAsync(
string taskId,
string? model,
string? systemPrompt,
string? agentPath,
CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Model, model)
.SetProperty(t => t.SystemPrompt, systemPrompt)
.SetProperty(t => t.AgentPath, agentPath), ct);
}
#endregion
#region Tags #region Tags
public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default) public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default)
@@ -186,7 +234,7 @@ public sealed class TaskRepository
WHERE lt.list_id = t.list_id AND tg.name = 'agent' WHERE lt.list_id = t.list_id AND tg.name = 'agent'
) )
) )
ORDER BY t.created_at ASC ORDER BY t.sort_order ASC, t.created_at ASC
LIMIT 1 LIMIT 1
) )
RETURNING * RETURNING *

View File

@@ -17,6 +17,8 @@ MVVM with CommunityToolkit.Mvvm source generators:
- **TaskEditorView** — Modal dialog for task create/edit - **TaskEditorView** — Modal dialog for task create/edit
- **ListEditorView** — Modal dialog for list create/edit - **ListEditorView** — Modal dialog for list create/edit
- **StatusBarView** — Connection status indicator, active task display - **StatusBarView** — Connection status indicator, active task display
- **ListSettingsModalView** — edits list name, working dir, default commit type, and per-list Model/SystemPrompt/AgentPath. Opened via context menu or gear button on a list row.
- **DetailsIslandView** — contains an "Agent settings (overrides)" expander with per-task Model/SystemPrompt/AgentPath, showing inherited effective values. Disabled while task is running.
All views use compiled bindings (`x:DataType`). All views use compiled bindings (`x:DataType`).
@@ -31,7 +33,7 @@ All views use compiled bindings (`x:DataType`).
## Services ## Services
- **WorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`. Auto-reconnect with exponential backoff. Methods: StartAsync, RunNowAsync, CancelTaskAsync, WakeQueueAsync, ContinueTaskAsync, ResetTaskAsync. Events: TaskStarted, TaskFinished, TaskMessage, TaskUpdated, WorktreeUpdated - **WorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`. Auto-reconnect with exponential backoff. Methods: StartAsync, RunNowAsync, CancelTaskAsync, WakeQueueAsync, ContinueTaskAsync, ResetTaskAsync, GetAgentsAsync, UpdateAppSettingsAsync, UpdateListAsync, UpdateListConfigAsync, GetListConfigAsync, UpdateTaskAgentSettingsAsync. Events: TaskStarted, TaskFinished, TaskMessage, TaskUpdated, WorktreeUpdated, ListUpdated
## Converters ## Converters

View File

@@ -272,6 +272,18 @@
</ControlTemplate> </ControlTemplate>
</Setter> </Setter>
</Style> </Style>
<Style Selector="Button.flat:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
<Style Selector="Button.flat:pressed /template/ ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
<Style Selector="Button.flat:focus /template/ ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- TASK ROW --> <!-- TASK ROW -->

View File

@@ -45,6 +45,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
public event Action<string>? TaskUpdatedEvent; public event Action<string>? TaskUpdatedEvent;
public event Action<string>? WorktreeUpdatedEvent; public event Action<string>? WorktreeUpdatedEvent;
public event Action<string>? RunNowRequestedEvent; public event Action<string>? RunNowRequestedEvent;
public event Action<string>? ListUpdatedEvent;
public WorkerClient(string signalRUrl) public WorkerClient(string signalRUrl)
{ {
@@ -110,6 +111,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
{ {
Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId)); Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId));
}); });
_hub.On<string>("ListUpdated", listId =>
{
Dispatcher.UIThread.Post(() => ListUpdatedEvent?.Invoke(listId));
});
} }
public Task StartAsync() public Task StartAsync()
@@ -271,6 +277,26 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
await _hub.InvokeAsync("UpdateAppSettings", dto); await _hub.InvokeAsync("UpdateAppSettings", dto);
} }
public async Task UpdateListAsync(UpdateListDto dto)
{
await _hub.InvokeAsync("UpdateList", dto);
}
public async Task UpdateListConfigAsync(UpdateListConfigDto dto)
{
await _hub.InvokeAsync("UpdateListConfig", dto);
}
public async Task<ListConfigDto?> GetListConfigAsync(string listId)
{
return await _hub.InvokeAsync<ListConfigDto?>("GetListConfig", listId);
}
public async Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto)
{
await _hub.InvokeAsync("UpdateTaskAgentSettings", dto);
}
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync() public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync()
{ {
try try
@@ -318,3 +344,7 @@ public sealed record WorktreeCleanupDto(int Removed);
public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks); public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage); public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches); public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath);
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath);
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath);

View File

@@ -3,6 +3,7 @@ using System.Text;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Helpers; using ClaudeDo.Ui.Helpers;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
@@ -53,9 +54,32 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
OnPropertyChanged(nameof(IsRunning)); OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(IsDone)); OnPropertyChanged(nameof(IsDone));
OnPropertyChanged(nameof(IsFailed)); OnPropertyChanged(nameof(IsFailed));
OnPropertyChanged(nameof(IsAgentSectionEnabled));
ShowFailedActions = value == "Failed"; ShowFailedActions = value == "Failed";
} }
[ObservableProperty] private string? _model; [ObservableProperty] private string? _model;
// Agent settings overrides
[ObservableProperty] private string _taskModelSelection = "(inherit)";
[ObservableProperty] private string _taskSystemPrompt = "";
[ObservableProperty] private AgentInfo? _taskSelectedAgent;
[ObservableProperty] private string _effectiveModelHint = "";
[ObservableProperty] private string _effectiveSystemPromptHint = "";
[ObservableProperty] private string _effectiveAgentHint = "";
public System.Collections.ObjectModel.ObservableCollection<string> TaskModelOptions { get; } = new()
{
"(inherit)", "sonnet", "opus", "haiku",
};
public System.Collections.ObjectModel.ObservableCollection<AgentInfo> TaskAgentOptions { get; } = new();
private bool _suppressAgentSave;
private CancellationTokenSource? _agentSaveCts;
public bool IsAgentSectionEnabled => !IsRunning;
[ObservableProperty] private string? _worktreePath; [ObservableProperty] private string? _worktreePath;
[ObservableProperty] private string? _worktreeBaseCommit; [ObservableProperty] private string? _worktreeBaseCommit;
[ObservableProperty] private string? _worktreeStateLabel; [ObservableProperty] private string? _worktreeStateLabel;
@@ -86,6 +110,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
public ObservableCollection<LogLineViewModel> Log { get; } = new(); public ObservableCollection<LogLineViewModel> Log { get; } = new();
public ObservableCollection<SubtaskRowViewModel> Subtasks { get; } = new(); public ObservableCollection<SubtaskRowViewModel> Subtasks { get; } = new();
[ObservableProperty] private string _newSubtaskTitle = "";
// Claude CLI stream-json parser + buffer for partial text deltas // Claude CLI stream-json parser + buffer for partial text deltas
private readonly StreamLineFormatter _formatter = new(); private readonly StreamLineFormatter _formatter = new();
private readonly StringBuilder _claudeBuf = new(); private readonly StringBuilder _claudeBuf = new();
@@ -212,6 +238,67 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece }); Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece });
} }
partial void OnTaskModelSelectionChanged(string value) => QueueAgentSave();
partial void OnTaskSystemPromptChanged(string value) => QueueAgentSave();
partial void OnTaskSelectedAgentChanged(AgentInfo? value) => QueueAgentSave();
private void QueueAgentSave()
{
if (_suppressAgentSave || Task is null) return;
_agentSaveCts?.Cancel();
_agentSaveCts = new CancellationTokenSource();
var ct = _agentSaveCts.Token;
_ = SaveAgentSettingsAsync(ct);
}
private async System.Threading.Tasks.Task SaveAgentSettingsAsync(CancellationToken ct)
{
try
{
await System.Threading.Tasks.Task.Delay(300, ct);
if (Task is null) return;
var model = TaskModelSelection == "(inherit)" ? null : TaskModelSelection;
var sp = string.IsNullOrWhiteSpace(TaskSystemPrompt) ? null : TaskSystemPrompt;
var ap = TaskSelectedAgent is null || string.IsNullOrWhiteSpace(TaskSelectedAgent.Path)
? null : TaskSelectedAgent.Path;
await _worker.UpdateTaskAgentSettingsAsync(
new ClaudeDo.Ui.Services.UpdateTaskAgentSettingsDto(Task.Id, model, sp, ap));
}
catch (OperationCanceledException) { }
catch { }
}
private async System.Threading.Tasks.Task LoadAgentSettingsAsync(
ClaudeDo.Data.Models.TaskEntity entity, CancellationToken ct)
{
_suppressAgentSave = true;
try
{
TaskAgentOptions.Clear();
TaskAgentOptions.Add(new AgentInfo("(inherit)", "", ""));
var agents = await _worker.GetAgentsAsync();
foreach (var a in agents) TaskAgentOptions.Add(a);
TaskModelSelection = string.IsNullOrWhiteSpace(entity.Model) ? "(inherit)" : entity.Model!;
TaskSystemPrompt = entity.SystemPrompt ?? "";
TaskSelectedAgent = string.IsNullOrWhiteSpace(entity.AgentPath)
? TaskAgentOptions[0]
: (TaskAgentOptions.FirstOrDefault(a => a.Path == entity.AgentPath) ?? TaskAgentOptions[0]);
var listCfg = await _worker.GetListConfigAsync(entity.ListId);
EffectiveModelHint = string.IsNullOrWhiteSpace(listCfg?.Model) ? "(global default)" : listCfg!.Model!;
EffectiveSystemPromptHint = string.IsNullOrWhiteSpace(listCfg?.SystemPrompt) ? "(none)" : listCfg!.SystemPrompt!;
EffectiveAgentHint = string.IsNullOrWhiteSpace(listCfg?.AgentPath)
? "(none)" : System.IO.Path.GetFileName(listCfg!.AgentPath!);
}
finally
{
_suppressAgentSave = false;
}
}
public void Bind(TaskRowViewModel? row) public void Bind(TaskRowViewModel? row)
{ {
_loadCts?.Cancel(); _loadCts?.Cancel();
@@ -237,6 +324,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
AgentStatusLabel = "Idle"; AgentStatusLabel = "Idle";
LatestRunSessionId = null; LatestRunSessionId = null;
ShowFailedActions = false; ShowFailedActions = false;
_suppressAgentSave = true;
try
{
TaskModelSelection = "(inherit)";
TaskSystemPrompt = "";
TaskSelectedAgent = null;
}
finally
{
_suppressAgentSave = false;
}
EffectiveModelHint = "";
EffectiveSystemPromptHint = "";
EffectiveAgentHint = "";
return; return;
} }
@@ -266,6 +367,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
WorktreeStateLabel = entity.Worktree?.State.ToString(); WorktreeStateLabel = entity.Worktree?.State.ToString();
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null; BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
AgentStatusLabel = entity.Status.ToString(); AgentStatusLabel = entity.Status.ToString();
await LoadAgentSettingsAsync(entity, ct);
ct.ThrowIfCancellationRequested();
var runRepo = new TaskRunRepository(ctx); var runRepo = new TaskRunRepository(ctx);
var latestRun = await runRepo.GetLatestByTaskIdAsync(row.Id, ct); var latestRun = await runRepo.GetLatestByTaskIdAsync(row.Id, ct);
@@ -386,6 +489,30 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
CloseDetail?.Invoke(); CloseDetail?.Invoke();
} }
[RelayCommand]
private async System.Threading.Tasks.Task AddSubtaskAsync()
{
if (Task is null) return;
var title = NewSubtaskTitle?.Trim();
if (string.IsNullOrEmpty(title)) return;
var entity = new ClaudeDo.Data.Models.SubtaskEntity
{
Id = Guid.NewGuid().ToString(),
TaskId = Task.Id,
Title = title,
Completed = false,
OrderNum = Subtasks.Count,
CreatedAt = DateTime.UtcNow,
};
await using var ctx = _dbFactory.CreateDbContext();
await new SubtaskRepository(ctx).AddAsync(entity);
Subtasks.Add(new SubtaskRowViewModel { Id = entity.Id, Title = entity.Title, Done = entity.Completed });
NewSubtaskTitle = "";
}
[RelayCommand] [RelayCommand]
private async System.Threading.Tasks.Task SaveNotesAsync() private async System.Threading.Tasks.Task SaveNotesAsync()
{ {

View File

@@ -9,6 +9,8 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
public required ListKind Kind { get; init; } public required ListKind Kind { get; init; }
[ObservableProperty] private int _count; [ObservableProperty] private int _count;
[ObservableProperty] private bool _isActive; [ObservableProperty] private bool _isActive;
[ObservableProperty] private string? _workingDir;
[ObservableProperty] private string _defaultCommitType = "chore";
public string? IconKey { get; init; } public string? IconKey { get; init; }
public string? DotColorKey { get; init; } public string? DotColorKey { get; init; }
} }

View File

@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals; using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -15,12 +16,14 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
{ {
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IServiceProvider? _services; private readonly IServiceProvider? _services;
private readonly WorkerClient? _worker;
public event EventHandler? SelectionChanged; public event EventHandler? SelectionChanged;
public event EventHandler? FocusSearchRequested; public event EventHandler? FocusSearchRequested;
public void RequestFocusSearch() => FocusSearchRequested?.Invoke(this, EventArgs.Empty); public void RequestFocusSearch() => FocusSearchRequested?.Invoke(this, EventArgs.Empty);
public Func<SettingsModalViewModel, Task>? ShowSettingsModal { get; set; } public Func<SettingsModalViewModel, Task>? ShowSettingsModal { get; set; }
public Func<ListSettingsModalViewModel, System.Threading.Tasks.Task>? ShowListSettingsModal { get; set; }
[RelayCommand] [RelayCommand]
private async Task OpenSettings() private async Task OpenSettings()
@@ -31,6 +34,16 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
await ShowSettingsModal(settingsVm); await ShowSettingsModal(settingsVm);
} }
[RelayCommand]
private async System.Threading.Tasks.Task OpenListSettingsAsync(ListNavItemViewModel? row)
{
if (row is null || ShowListSettingsModal is null || _services is null) return;
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
await vm.LoadAsync(row.Id, row.Name, row.WorkingDir, row.DefaultCommitType);
await ShowListSettingsModal(vm);
await RefreshRowAsync(row.Id);
}
public ObservableCollection<ListNavItemViewModel> Items { get; } = new(); public ObservableCollection<ListNavItemViewModel> Items { get; } = new();
public ObservableCollection<ListNavItemViewModel> SmartLists { get; } = new(); public ObservableCollection<ListNavItemViewModel> SmartLists { get; } = new();
public ObservableCollection<ListNavItemViewModel> UserLists { get; } = new(); public ObservableCollection<ListNavItemViewModel> UserLists { get; } = new();
@@ -42,16 +55,20 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
public string MachineName { get; } = Environment.MachineName; public string MachineName { get; } = Environment.MachineName;
public string UserInitials { get; } public string UserInitials { get; }
public ListsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IServiceProvider? services = null) public ListsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IServiceProvider? services = null, WorkerClient? worker = null)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_services = services; _services = services;
_worker = worker;
var parts = Environment.UserName.Split('.', '_', '-', ' '); var parts = Environment.UserName.Split('.', '_', '-', ' ');
UserInitials = parts.Length >= 2 UserInitials = parts.Length >= 2
? $"{parts[0][0]}{parts[1][0]}".ToUpperInvariant() ? $"{parts[0][0]}{parts[1][0]}".ToUpperInvariant()
: Environment.UserName.Length >= 2 : Environment.UserName.Length >= 2
? Environment.UserName[..2].ToUpperInvariant() ? Environment.UserName[..2].ToUpperInvariant()
: Environment.UserName.ToUpperInvariant(); : Environment.UserName.ToUpperInvariant();
if (_worker is not null)
_worker.ListUpdatedEvent += id => _ = RefreshRowAsync(id);
} }
public async Task LoadAsync(CancellationToken ct = default) public async Task LoadAsync(CancellationToken ct = default)
@@ -85,6 +102,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
Kind = ListKind.User, Kind = ListKind.User,
IconKey = "Folder", IconKey = "Folder",
DotColorKey = dotColors[idx % dotColors.Length], DotColorKey = dotColors[idx % dotColors.Length],
WorkingDir = l.WorkingDir,
DefaultCommitType = l.DefaultCommitType,
}; };
Items.Add(item); Items.Add(item);
UserLists.Add(item); UserLists.Add(item);
@@ -109,4 +128,23 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
foreach (var i in Items) i.IsActive = ReferenceEquals(i, value); foreach (var i in Items) i.IsActive = ReferenceEquals(i, value);
SelectionChanged?.Invoke(this, EventArgs.Empty); SelectionChanged?.Invoke(this, EventArgs.Empty);
} }
private async System.Threading.Tasks.Task RefreshRowAsync(string rowId)
{
try
{
var rawId = rowId.StartsWith("user:") ? rowId["user:".Length..] : rowId;
var row = UserLists.FirstOrDefault(r => r.Id == rowId);
if (row is null) return;
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = new ListRepository(ctx);
var entity = await lists.GetByIdAsync(rawId);
if (entity is null) return;
row.WorkingDir = entity.WorkingDir;
row.DefaultCommitType = entity.DefaultCommitType;
}
catch { /* best-effort refresh */ }
}
} }

View File

@@ -21,6 +21,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
[ObservableProperty] private DateTime? _scheduledFor; [ObservableProperty] private DateTime? _scheduledFor;
[ObservableProperty] private int _diffAdditions; [ObservableProperty] private int _diffAdditions;
[ObservableProperty] private int _diffDeletions; [ObservableProperty] private int _diffDeletions;
[ObservableProperty] private bool _dropHintAbove;
[ObservableProperty] private bool _dropHintBelow;
public DateTime CreatedAt { get; init; } public DateTime CreatedAt { get; init; }
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}"; public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}";

View File

@@ -75,6 +75,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
var all = await db.Tasks var all = await db.Tasks
.Include(t => t.List) .Include(t => t.List)
.Include(t => t.Worktree) .Include(t => t.Worktree)
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.ToListAsync(ct); .ToListAsync(ct);
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
@@ -149,23 +150,83 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
{ {
if (string.IsNullOrWhiteSpace(NewTaskTitle) || _currentList?.Kind != ListKind.User) return; if (string.IsNullOrWhiteSpace(NewTaskTitle) || _currentList?.Kind != ListKind.User) return;
var listId = _currentList.Id["user:".Length..]; var listId = _currentList.Id["user:".Length..];
await using var db = await _dbFactory.CreateDbContextAsync();
var maxSort = await db.Tasks
.Where(t => t.ListId == listId)
.Select(t => (int?)t.SortOrder)
.MaxAsync();
var entity = new TaskEntity var entity = new TaskEntity
{ {
Id = Guid.NewGuid().ToString("N"), Id = Guid.NewGuid().ToString("N"),
ListId = listId, ListId = listId,
Title = NewTaskTitle.Trim(), Title = NewTaskTitle.Trim(),
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
SortOrder = (maxSort ?? -1) + 1,
}; };
await using var db = await _dbFactory.CreateDbContextAsync();
db.Tasks.Add(entity); db.Tasks.Add(entity);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var row = TaskRowViewModel.FromEntity(entity); var row = TaskRowViewModel.FromEntity(entity);
Items.Insert(0, row); Items.Add(row);
Regroup(); Regroup();
NewTaskTitle = ""; NewTaskTitle = "";
UpdateSubtitle(); UpdateSubtitle();
} }
public bool CanReorder => _currentList?.Kind == ListKind.User;
public void ClearDropHints()
{
foreach (var r in Items)
{
r.DropHintAbove = false;
r.DropHintBelow = false;
}
}
public void SetDropHint(TaskRowViewModel target, bool placeBelow)
{
foreach (var r in Items)
{
var isTarget = ReferenceEquals(r, target);
r.DropHintAbove = isTarget && !placeBelow;
r.DropHintBelow = isTarget && placeBelow;
}
}
public async Task ReorderAsync(TaskRowViewModel source, TaskRowViewModel target, bool placeBelow)
{
if (!CanReorder || _currentList is null) return;
if (source.IsRunning || target.IsRunning) return;
if (ReferenceEquals(source, target)) return;
var srcIdx = Items.IndexOf(source);
var tgtIdx = Items.IndexOf(target);
if (srcIdx < 0 || tgtIdx < 0) return;
Items.RemoveAt(srcIdx);
var newTgtIdx = Items.IndexOf(target);
var insertIdx = placeBelow ? newTgtIdx + 1 : newTgtIdx;
if (insertIdx < 0 || insertIdx > Items.Count) insertIdx = Items.Count;
Items.Insert(insertIdx, source);
var listId = _currentList.Id["user:".Length..];
var orderedIds = Items.Select(i => i.Id).ToList();
await using var db = await _dbFactory.CreateDbContextAsync();
var idSet = orderedIds.ToHashSet();
var entities = await db.Tasks
.Where(t => t.ListId == listId && idSet.Contains(t.Id))
.ToListAsync();
for (int i = 0; i < orderedIds.Count; i++)
{
var e = entities.FirstOrDefault(x => x.Id == orderedIds[i]);
if (e is not null) e.SortOrder = i;
}
await db.SaveChangesAsync();
Regroup();
}
[RelayCommand] [RelayCommand]
private async Task ToggleDoneAsync(TaskRowViewModel row) private async Task ToggleDoneAsync(TaskRowViewModel row)
{ {

View File

@@ -0,0 +1,96 @@
using System.Collections.ObjectModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class ListSettingsModalViewModel : ViewModelBase
{
private readonly WorkerClient _worker;
public string ListId { get; set; } = "";
[ObservableProperty] private string _name = "";
[ObservableProperty] private string _workingDir = "";
[ObservableProperty] private string _defaultCommitType = "chore";
[ObservableProperty] private string _selectedModel = "(default)";
[ObservableProperty] private string _systemPrompt = "";
[ObservableProperty] private AgentInfo? _selectedAgent;
public ObservableCollection<string> ModelOptions { get; } = new()
{
"(default)", "sonnet", "opus", "haiku",
};
public ObservableCollection<string> CommitTypeOptions { get; } = new()
{
"chore", "feat", "fix", "refactor", "docs", "test", "ci", "perf", "style", "build",
};
public ObservableCollection<AgentInfo> Agents { get; } = new();
public Action? CloseAction { get; set; }
public ListSettingsModalViewModel(WorkerClient worker)
{
_worker = worker;
}
public async Task LoadAsync(
string listId,
string name,
string? workingDir,
string defaultCommitType,
CancellationToken ct = default)
{
ListId = listId;
Name = name;
WorkingDir = workingDir ?? "";
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? "chore" : defaultCommitType;
Agents.Clear();
Agents.Add(new AgentInfo("(none)", "", ""));
var agents = await _worker.GetAgentsAsync();
foreach (var a in agents) Agents.Add(a);
var config = await _worker.GetListConfigAsync(listId);
SelectedModel = string.IsNullOrWhiteSpace(config?.Model) ? "(default)" : config!.Model!;
SystemPrompt = config?.SystemPrompt ?? "";
SelectedAgent = string.IsNullOrWhiteSpace(config?.AgentPath)
? Agents[0]
: (Agents.FirstOrDefault(a => a.Path == config!.AgentPath) ?? Agents[0]);
}
[RelayCommand]
private async Task SaveAsync()
{
var model = SelectedModel == "(default)" ? null : SelectedModel;
var sp = string.IsNullOrWhiteSpace(SystemPrompt) ? null : SystemPrompt;
var ap = SelectedAgent is null || string.IsNullOrWhiteSpace(SelectedAgent.Path) ? null : SelectedAgent.Path;
await _worker.UpdateListAsync(new UpdateListDto(
ListId,
string.IsNullOrWhiteSpace(Name) ? "Untitled" : Name,
string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
DefaultCommitType));
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(
ListId, model, sp, ap));
CloseAction?.Invoke();
}
[RelayCommand]
private void Cancel() => CloseAction?.Invoke();
[RelayCommand]
private void ResetAgentSettings()
{
SelectedModel = "(default)";
SystemPrompt = "";
SelectedAgent = Agents.Count > 0 ? Agents[0] : null;
}
}

View File

@@ -12,7 +12,6 @@
BorderThickness="0,1,0,0" BorderThickness="0,1,0,0"
Padding="14,8"> Padding="14,8">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<!-- Delete button -->
<Button Grid.Column="0" Classes="icon-btn" <Button Grid.Column="0" Classes="icon-btn"
Command="{Binding DeleteTaskCommand}" Command="{Binding DeleteTaskCommand}"
ToolTip.Tip="Delete task" ToolTip.Tip="Delete task"
@@ -20,14 +19,12 @@
<PathIcon Data="{StaticResource Icon.Trash}" Width="14" Height="14" <PathIcon Data="{StaticResource Icon.Trash}" Width="14" Height="14"
Foreground="{DynamicResource BloodBrush}"/> Foreground="{DynamicResource BloodBrush}"/>
</Button> </Button>
<!-- Created date -->
<TextBlock Grid.Column="1" <TextBlock Grid.Column="1"
Text="{Binding Task.CreatedAtFormatted}" Text="{Binding Task.CreatedAtFormatted}"
FontFamily="{DynamicResource MonoFont}" FontSize="10" FontFamily="{DynamicResource MonoFont}" FontSize="10"
Foreground="{DynamicResource TextFaintBrush}" Foreground="{DynamicResource TextFaintBrush}"
HorizontalAlignment="Center" HorizontalAlignment="Center"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<!-- Close button -->
<Button Grid.Column="2" Classes="icon-btn" <Button Grid.Column="2" Classes="icon-btn"
Command="{Binding CloseDetailsCommand}" Command="{Binding CloseDetailsCommand}"
ToolTip.Tip="Close" ToolTip.Tip="Close"
@@ -37,10 +34,10 @@
</Grid> </Grid>
</Border> </Border>
<!-- ── Header ── --> <!-- ── Header (sticky top): eyebrow · title · gear (agent-settings flyout) ── -->
<Border DockPanel.Dock="Top" Classes="island-header"> <Border DockPanel.Dock="Top" Classes="island-header">
<!-- Eyebrow row --> <Grid ColumnDefinitions="*,Auto">
<StackPanel Spacing="0"> <StackPanel Grid.Column="0" Spacing="0">
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,4"> <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,4">
<Ellipse Width="5" Height="5" Fill="{DynamicResource AccentBrush}" <Ellipse Width="5" Height="5" Fill="{DynamicResource AccentBrush}"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
@@ -51,16 +48,62 @@
VerticalAlignment="Center" VerticalAlignment="Center"
Margin="8,0,0,0"/> Margin="8,0,0,0"/>
</StackPanel> </StackPanel>
<!-- Editable title (reduced size) -->
<TextBox Text="{Binding EditableTitle, Mode=TwoWay}" <TextBox Text="{Binding EditableTitle, Mode=TwoWay}"
FontSize="14" FontWeight="Medium" FontSize="14" FontWeight="Medium"
BorderThickness="0" Background="Transparent" BorderThickness="0" Background="Transparent"
Foreground="{DynamicResource TextBrush}" Foreground="{DynamicResource TextBrush}"
Padding="0"/> Padding="0"/>
</StackPanel> </StackPanel>
<Button Grid.Column="1" Classes="icon-btn"
ToolTip.Tip="Agent settings"
IsEnabled="{Binding IsAgentSectionEnabled}"
VerticalAlignment="Top"
Margin="6,0,0,0">
<TextBlock Text="⚙" FontSize="14"/>
<Button.Flyout>
<Flyout Placement="BottomEdgeAlignedRight" ShowMode="Standard">
<StackPanel Width="340" Spacing="10" Margin="4">
<TextBlock Text="Agent settings (overrides)" FontWeight="SemiBold"/>
<StackPanel Spacing="2">
<TextBlock Text="Model"/>
<ComboBox ItemsSource="{Binding TaskModelOptions}"
SelectedItem="{Binding TaskModelSelection, Mode=TwoWay}"
HorizontalAlignment="Stretch"/>
<TextBlock Text="{Binding EffectiveModelHint, StringFormat='Effective if inherited: {0}'}"
Opacity="0.6" FontSize="11"/>
</StackPanel>
<StackPanel Spacing="2">
<TextBlock Text="System prompt (appended)"/>
<TextBox Text="{Binding TaskSystemPrompt, Mode=TwoWay}"
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="70"
PlaceholderText="{Binding EffectiveSystemPromptHint}"/>
</StackPanel>
<StackPanel Spacing="2">
<TextBlock Text="Agent file"/>
<ComboBox ItemsSource="{Binding TaskAgentOptions}"
SelectedItem="{Binding TaskSelectedAgent, Mode=TwoWay}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="{Binding EffectiveAgentHint, StringFormat='Effective if inherited: {0}'}"
Opacity="0.6" FontSize="11"/>
</StackPanel>
</StackPanel>
</Flyout>
</Button.Flyout>
</Button>
</Grid>
</Border> </Border>
<!-- ── Task strip row: check + title display + star ── --> <!-- ── Task strip row (sticky top): check + title + star ── -->
<Border DockPanel.Dock="Top" <Border DockPanel.Dock="Top"
Padding="18,10,18,10" Padding="18,10,18,10"
BorderBrush="{DynamicResource LineBrush}" BorderBrush="{DynamicResource LineBrush}"
@@ -88,26 +131,32 @@
</Grid> </Grid>
</Border> </Border>
<!-- ── Main body: agent strip (auto) · terminal (flex) · steps+notes (auto/capped) ── --> <!-- ── Agent status strip (sticky, above metadata footer) ── -->
<Grid RowDefinitions="Auto,*,Auto"> <islands:AgentStripView DockPanel.Dock="Bottom"/>
<!-- Agent strip --> <!-- ── Scrollable body: steps + terminal ── -->
<islands:AgentStripView Grid.Row="0"/> <ScrollViewer VerticalScrollBarVisibility="Auto">
<!-- Session terminal — fills remaining vertical space -->
<islands:SessionTerminalView Grid.Row="1" MinHeight="220" Margin="0,0,0,0"/>
<!-- Steps + Notes in a capped scroller so they never squeeze the terminal -->
<ScrollViewer Grid.Row="2" MaxHeight="240"
VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="0"> <StackPanel Spacing="0">
<!-- Subtasks section --> <!-- Steps section -->
<StackPanel Margin="18,12,18,0" <Border Padding="18,12,18,12"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1">
<StackPanel Spacing="6">
<TextBlock Classes="section-label" Text="STEPS" Margin="0,0,0,2"/>
<TextBox Text="{Binding NewSubtaskTitle, Mode=TwoWay}"
PlaceholderText="Add a step..."
Padding="8"
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1"
CornerRadius="6">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AddSubtaskCommand}"/>
</TextBox.KeyBindings>
</TextBox>
<ItemsControl ItemsSource="{Binding Subtasks}"
IsVisible="{Binding Subtasks.Count}"> IsVisible="{Binding Subtasks.Count}">
<TextBlock Classes="section-label" Text="STEPS"
Margin="0,0,0,6"/>
<ItemsControl ItemsSource="{Binding Subtasks}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SubtaskRowViewModel"> <DataTemplate DataType="vm:SubtaskRowViewModel">
<Border Classes="subtask-row" <Border Classes="subtask-row"
@@ -133,25 +182,13 @@
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
</StackPanel> </StackPanel>
</Border>
<!-- Notes section --> <!-- Session terminal — auto-sizes to output, scrolls internally after MaxHeight -->
<StackPanel Margin="18,12,18,12"> <islands:SessionTerminalView MaxHeight="420"/>
<TextBlock Classes="section-label" Text="NOTES" Margin="0,0,0,6"/>
<TextBox Text="{Binding Notes, Mode=TwoWay}"
AcceptsReturn="True"
TextWrapping="Wrap"
MinHeight="80"
Padding="12"
PlaceholderText="Notes..."
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1"
CornerRadius="8"
LostFocus="NotesLostFocus"/>
</StackPanel>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</Grid>
</DockPanel> </DockPanel>
</UserControl> </UserControl>

View File

@@ -66,7 +66,7 @@
</StackPanel> </StackPanel>
<!-- More button --> <!-- More button -->
<Button Grid.Column="2" Classes="icon-btn" VerticalAlignment="Center" <Button Grid.Column="2" Classes="icon-btn" VerticalAlignment="Center"
Command="{Binding OpenSettingsCommand}" Command="{Binding OpenListSettingsCommand}"
ToolTip.Tip="Settings"> ToolTip.Tip="Settings">
<PathIcon Data="{StaticResource Icon.MoreHorizontal}" <PathIcon Data="{StaticResource Icon.MoreHorizontal}"
Width="14" Height="14" Width="14" Height="14"
@@ -130,9 +130,16 @@
<DataTemplate DataType="vm:ListNavItemViewModel"> <DataTemplate DataType="vm:ListNavItemViewModel">
<Border Classes="list-item" Classes.active="{Binding IsActive}" <Border Classes="list-item" Classes.active="{Binding IsActive}"
Tapped="OnItemTapped"> Tapped="OnItemTapped">
<Grid ColumnDefinitions="20,*,Auto"> <Border.ContextMenu>
<ContextMenu>
<MenuItem Header="Settings..."
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
CommandParameter="{Binding}"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="20,*,Auto,Auto">
<!-- Left accent bar for active state --> <!-- Left accent bar for active state -->
<Border Grid.Column="0" Grid.ColumnSpan="3" <Border Grid.Column="0" Grid.ColumnSpan="4"
Background="Transparent" Background="Transparent"
CornerRadius="8" IsHitTestVisible="False" CornerRadius="8" IsHitTestVisible="False"
IsVisible="{Binding IsActive}"> IsVisible="{Binding IsActive}">
@@ -158,6 +165,18 @@
FontFamily="{DynamicResource MonoFamily}" FontSize="10" FontFamily="{DynamicResource MonoFamily}" FontSize="10"
Foreground="{DynamicResource TextFaintBrush}" Foreground="{DynamicResource TextFaintBrush}"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<!-- Gear button -->
<Button Grid.Column="3"
Content="⚙"
ToolTip.Tip="Settings..."
Background="Transparent"
BorderThickness="0"
Padding="4,0"
FontSize="11"
Foreground="{DynamicResource TextFaintBrush}"
VerticalAlignment="Center"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
CommandParameter="{Binding}"/>
</Grid> </Grid>
</Border> </Border>
</DataTemplate> </DataTemplate>

View File

@@ -17,6 +17,14 @@ public partial class ListsIslandView : UserControl
{ {
vm.FocusSearchRequested += (_, _) => SearchBox.Focus(); vm.FocusSearchRequested += (_, _) => SearchBox.Focus();
vm.ShowSettingsModal = ShowSettingsAsync; vm.ShowSettingsModal = ShowSettingsAsync;
vm.ShowListSettingsModal = async modal =>
{
var window = new ListSettingsModalView { DataContext = modal };
modal.CloseAction = () => window.Close();
var top = TopLevel.GetTopLevel(this) as Window;
if (top is null) window.Show();
else await window.ShowDialog(top);
};
} }
}; };
} }

View File

@@ -51,7 +51,7 @@
<!-- ── Log output ── --> <!-- ── Log output ── -->
<ScrollViewer Name="LogScroll" VerticalScrollBarVisibility="Auto" <ScrollViewer Name="LogScroll" VerticalScrollBarVisibility="Auto"
Padding="10,8,10,4"> Padding="10,8,10,12">
<ItemsControl ItemsSource="{Binding Log}"> <ItemsControl ItemsSource="{Binding Log}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:LogLineViewModel"> <DataTemplate DataType="vm:LogLineViewModel">

View File

@@ -1,5 +1,6 @@
using System.Collections.Specialized; using System.Collections.Specialized;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Threading;
using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Views.Islands; namespace ClaudeDo.Ui.Views.Islands;
@@ -17,7 +18,7 @@ public partial class SessionTerminalView : UserControl
private void OnLogChanged(object? sender, NotifyCollectionChangedEventArgs e) private void OnLogChanged(object? sender, NotifyCollectionChangedEventArgs e)
{ {
if (e.Action == NotifyCollectionChangedAction.Add) if (e.Action != NotifyCollectionChangedAction.Add) return;
LogScroll.ScrollToEnd(); Dispatcher.UIThread.Post(() => LogScroll.ScrollToEnd(), DispatcherPriority.Background);
} }
} }

View File

@@ -3,7 +3,20 @@
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands" xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
x:Class="ClaudeDo.Ui.Views.Islands.TaskRowView" x:Class="ClaudeDo.Ui.Views.Islands.TaskRowView"
x:DataType="vm:TaskRowViewModel"> x:DataType="vm:TaskRowViewModel">
<Border Classes="task-row" <Grid>
<Grid.RowDefinitions>
<RowDefinition Height="6"/>
<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}"/>
<Border Grid.Row="1" Classes="task-row"
Margin="0"
Classes.selected="{Binding IsSelected}" Classes.selected="{Binding IsSelected}"
Classes.done="{Binding Done}"> Classes.done="{Binding Done}">
<Grid ColumnDefinitions="4,32,*,32" Margin="6,8,10,8"> <Grid ColumnDefinitions="4,32,*,32" Margin="6,8,10,8">
@@ -110,4 +123,11 @@
</Button> </Button>
</Grid> </Grid>
</Border> </Border>
<!-- 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>
</UserControl> </UserControl>

View File

@@ -80,7 +80,13 @@
<Button Classes="flat" HorizontalAlignment="Stretch" <Button Classes="flat" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch" HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).SelectCommand}" Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).SelectCommand}"
CommandParameter="{Binding}"> CommandParameter="{Binding}"
PointerPressed="OnRowPointerPressed"
PointerMoved="OnRowPointerMoved"
PointerReleased="OnRowPointerReleased"
DragDrop.AllowDrop="True"
DragDrop.DragOver="OnRowDragOver"
DragDrop.Drop="OnRowDrop">
<islands:TaskRowView/> <islands:TaskRowView/>
</Button> </Button>
</DataTemplate> </DataTemplate>
@@ -99,7 +105,13 @@
<Button Classes="flat" HorizontalAlignment="Stretch" <Button Classes="flat" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch" HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).SelectCommand}" Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).SelectCommand}"
CommandParameter="{Binding}"> CommandParameter="{Binding}"
PointerPressed="OnRowPointerPressed"
PointerMoved="OnRowPointerMoved"
PointerReleased="OnRowPointerReleased"
DragDrop.AllowDrop="True"
DragDrop.DragOver="OnRowDragOver"
DragDrop.Drop="OnRowDrop">
<islands:TaskRowView/> <islands:TaskRowView/>
</Button> </Button>
</DataTemplate> </DataTemplate>
@@ -123,7 +135,13 @@
<Button Classes="flat" HorizontalAlignment="Stretch" <Button Classes="flat" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch" HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).SelectCommand}" Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).SelectCommand}"
CommandParameter="{Binding}"> CommandParameter="{Binding}"
PointerPressed="OnRowPointerPressed"
PointerMoved="OnRowPointerMoved"
PointerReleased="OnRowPointerReleased"
DragDrop.AllowDrop="True"
DragDrop.DragOver="OnRowDragOver"
DragDrop.Drop="OnRowDrop">
<islands:TaskRowView/> <islands:TaskRowView/>
</Button> </Button>
</DataTemplate> </DataTemplate>

View File

@@ -1,17 +1,150 @@
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Views.Islands; namespace ClaudeDo.Ui.Views.Islands;
public partial class TasksIslandView : UserControl public partial class TasksIslandView : UserControl
{ {
private static readonly DataFormat<string> TaskRowFormat =
DataFormat.CreateStringApplicationFormat("claudedo-task-row");
public TasksIslandView() public TasksIslandView()
{ {
InitializeComponent(); InitializeComponent();
// Tunnel handler runs BEFORE Button's class handler so we can start a drag
// without the Button first marking the event as handled.
AddHandler(PointerPressedEvent, OnTunnelPointerPressed, RoutingStrategies.Tunnel);
DataContextChanged += (_, _) => DataContextChanged += (_, _) =>
{ {
if (DataContext is TasksIslandViewModel vm) if (DataContext is TasksIslandViewModel vm)
vm.FocusAddTaskRequested += (_, _) => AddTaskBox.Focus(); vm.FocusAddTaskRequested += (_, _) => AddTaskBox.Focus();
}; };
} }
private async void OnTunnelPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (DataContext is not TasksIslandViewModel vm || !vm.CanReorder) return;
if (e.Source is not Visual src) return;
var button = src as Button ?? src.FindAncestorOfType<Button>();
if (button?.DataContext is not TaskRowViewModel row) return;
if (row.IsRunning) return;
if (!e.GetCurrentPoint(button).Properties.IsLeftButtonPressed) return;
var data = new DataTransfer();
data.Add(DataTransferItem.Create(TaskRowFormat, row.Id));
try
{
await DragDrop.DoDragDropAsync(e, data, DragDropEffects.Move);
}
finally
{
vm.ClearDropHints();
}
}
private void OnRowPointerPressed(object? sender, PointerPressedEventArgs e) { }
private void OnRowPointerMoved(object? sender, PointerEventArgs e) { }
private void OnRowPointerReleased(object? sender, PointerReleasedEventArgs e) { }
private void OnRowDragOver(object? sender, DragEventArgs e)
{
if (DataContext is not TasksIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; }
if (!e.DataTransfer?.Contains(TaskRowFormat) ?? true)
{
e.DragEffects = DragDropEffects.None;
vm.ClearDropHints();
return;
}
if (sender is not Button b || b.DataContext is not TaskRowViewModel target || target.IsRunning)
{
e.DragEffects = DragDropEffects.None;
vm.ClearDropHints();
return;
}
var sourceId = e.DataTransfer?.TryGetValue(TaskRowFormat);
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". Render the indicator
// above X+1 when there is one; only the last row in a section shows a below-line.
TaskRowViewModel hintRow = target;
bool hintBelow = false;
if (placeBelow)
{
var next = FindNextInSameSection(vm, target);
if (next is not null && !next.IsRunning)
{
hintRow = next;
hintBelow = false;
}
else
{
hintRow = target;
hintBelow = true;
}
}
// A hint that lands right where the dragged row already sits is a no-op.
if (hintRow.Id == sourceId)
{
e.DragEffects = DragDropEffects.None;
vm.ClearDropHints();
return;
}
vm.SetDropHint(hintRow, hintBelow);
e.DragEffects = DragDropEffects.Move;
}
private static TaskRowViewModel? FindNextInSameSection(TasksIslandViewModel vm, TaskRowViewModel row)
{
foreach (var section in new[] { vm.OverdueItems, vm.OpenItems, vm.CompletedItems })
{
var idx = section.IndexOf(row);
if (idx >= 0) return idx + 1 < section.Count ? section[idx + 1] : null;
}
return null;
}
private async void OnRowDrop(object? sender, DragEventArgs e)
{
if (DataContext is not TasksIslandViewModel vm) return;
try
{
if (sender is not Button b || b.DataContext is not TaskRowViewModel target) return;
if (target.IsRunning) return;
var sourceId = e.DataTransfer?.TryGetValue(TaskRowFormat);
if (string.IsNullOrEmpty(sourceId) || sourceId == target.Id) return;
var source = FindRowById(vm, sourceId);
if (source is null || source.IsRunning) return;
var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2;
await vm.ReorderAsync(source, target, placeBelow);
}
finally
{
vm.ClearDropHints();
}
}
private static TaskRowViewModel? FindRowById(TasksIslandViewModel vm, string id)
{
foreach (var r in vm.Items)
if (r.Id == id) return r;
return null;
}
} }

View File

@@ -0,0 +1,80 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:ClaudeDo.Ui.ViewModels.Modals"
x:Class="ClaudeDo.Ui.Views.Modals.ListSettingsModalView"
x:DataType="vm:ListSettingsModalViewModel"
Title="List settings"
Width="520" Height="600"
WindowStartupLocation="CenterOwner"
CanResize="False">
<DockPanel Margin="16">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,16,0,0">
<Button Content="Cancel" Command="{Binding CancelCommand}" />
<Button Content="Save" Command="{Binding SaveCommand}" Classes="accent" />
</StackPanel>
<ScrollViewer>
<StackPanel Spacing="16">
<TextBlock Text="General" FontSize="16" FontWeight="SemiBold" />
<StackPanel Spacing="4">
<TextBlock Text="Name" />
<TextBox Text="{Binding Name}" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Working directory" />
<Grid ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0" Text="{Binding WorkingDir}" PlaceholderText="(none)" />
<Button Grid.Column="1" Content="Browse..." Margin="8,0,0,0" Click="BrowseClicked" />
</Grid>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Default commit type" />
<ComboBox ItemsSource="{Binding CommitTypeOptions}"
SelectedItem="{Binding DefaultCommitType, Mode=TwoWay}"
HorizontalAlignment="Left" MinWidth="160" />
</StackPanel>
<Separator Margin="0,8,0,8" />
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Agent" FontSize="16" FontWeight="SemiBold" />
<Button Grid.Column="1" Content="Reset agent settings"
Command="{Binding ResetAgentSettingsCommand}" />
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Model" />
<ComboBox ItemsSource="{Binding ModelOptions}"
SelectedItem="{Binding SelectedModel, Mode=TwoWay}"
HorizontalAlignment="Left" MinWidth="160" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="System prompt (appended)" />
<TextBox Text="{Binding SystemPrompt, Mode=TwoWay}"
AcceptsReturn="True" TextWrapping="Wrap"
MinHeight="80" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Agent file" />
<ComboBox ItemsSource="{Binding Agents}"
SelectedItem="{Binding SelectedAgent, Mode=TwoWay}"
HorizontalAlignment="Left" MinWidth="240">
<ComboBox.ItemTemplate>
<DataTemplate>
<StackPanel>
<TextBlock Text="{Binding Name}" />
<TextBlock Text="{Binding Description}" Opacity="0.6" FontSize="11" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</StackPanel>
</ScrollViewer>
</DockPanel>
</Window>

View File

@@ -0,0 +1,31 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals;
public partial class ListSettingsModalView : Window
{
public ListSettingsModalView()
{
InitializeComponent();
}
private async void BrowseClicked(object? sender, RoutedEventArgs e)
{
if (DataContext is not ListSettingsModalViewModel vm) return;
var top = TopLevel.GetTopLevel(this);
if (top is null) return;
var folders = await top.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Choose working directory",
AllowMultiple = false,
});
if (folders.Count > 0)
{
vm.WorkingDir = folders[0].Path.LocalPath;
}
}
}

View File

@@ -45,9 +45,9 @@ Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`
## SignalR Hub ## SignalR Hub
**WorkerHub** methods: `Ping()`, `GetActive()`, `RunNow(taskId)`, `CancelTask(taskId)`, `WakeQueue()`, `ContinueTask(taskId, prompt)`, `ResetTask(taskId)`, `GetAgents()`, `RefreshAgents()` **WorkerHub** methods: `Ping`, `GetActive`, `RunNow`, `CancelTask`, `WakeQueue`, `ContinueTask`, `ResetTask`, `GetAgents`, `RefreshAgents`, `GetAppSettings`, `UpdateAppSettings`, `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `MergeTask`, `GetMergeTargets`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`
**HubBroadcaster** events: `TaskStarted`, `TaskFinished`, `TaskMessage`, `WorktreeUpdated`, `TaskUpdated`, `RunCreated` **HubBroadcaster** events: `TaskStarted`, `TaskFinished`, `TaskMessage`, `WorktreeUpdated`, `TaskUpdated`, `RunCreated`, `ListUpdated`
## Config ## Config

View File

@@ -23,6 +23,9 @@ public sealed class HubBroadcaster
public Task TaskUpdated(string taskId) => public Task TaskUpdated(string taskId) =>
_hub.Clients.All.SendAsync("TaskUpdated", taskId); _hub.Clients.All.SendAsync("TaskUpdated", taskId);
public Task ListUpdated(string listId) =>
_hub.Clients.All.SendAsync("ListUpdated", listId);
public Task RunCreated(string taskId, int runNumber, bool isRetry) => public Task RunCreated(string taskId, int runNumber, bool isRetry) =>
_hub.Clients.All.SendAsync("RunCreated", taskId, runNumber, isRetry); _hub.Clients.All.SendAsync("RunCreated", taskId, runNumber, isRetry);
} }

View File

@@ -24,6 +24,10 @@ public record WorktreeCleanupDto(int Removed);
public record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks); public record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage); public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches); public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath);
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath);
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath);
public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
{ {
@@ -205,4 +209,70 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
throw new HubException(ex.Message); throw new HubException(ex.Message);
} }
} }
public async Task UpdateList(UpdateListDto dto)
{
using var ctx = _dbFactory.CreateDbContext();
var repo = new ListRepository(ctx);
var entity = await repo.GetByIdAsync(dto.Id);
if (entity is null) throw new HubException("list not found");
entity.Name = dto.Name;
entity.WorkingDir = string.IsNullOrWhiteSpace(dto.WorkingDir) ? null : dto.WorkingDir;
entity.DefaultCommitType = string.IsNullOrWhiteSpace(dto.DefaultCommitType) ? "chore" : dto.DefaultCommitType;
await repo.UpdateAsync(entity);
await _broadcaster.ListUpdated(dto.Id);
}
public async Task UpdateListConfig(UpdateListConfigDto dto)
{
using var ctx = _dbFactory.CreateDbContext();
var repo = new ListRepository(ctx);
var model = Nullify(dto.Model);
var systemPrompt = Nullify(dto.SystemPrompt);
var agentPath = Nullify(dto.AgentPath);
if (model is null && systemPrompt is null && agentPath is null)
{
await repo.DeleteConfigAsync(dto.ListId);
}
else
{
await repo.SetConfigAsync(new ListConfigEntity
{
ListId = dto.ListId,
Model = model,
SystemPrompt = systemPrompt,
AgentPath = agentPath,
});
}
await _broadcaster.ListUpdated(dto.ListId);
}
public async Task<ListConfigDto?> GetListConfig(string listId)
{
using var ctx = _dbFactory.CreateDbContext();
var repo = new ListRepository(ctx);
var config = await repo.GetConfigAsync(listId);
if (config is null) return null;
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath);
}
public async Task UpdateTaskAgentSettings(UpdateTaskAgentSettingsDto dto)
{
using var ctx = _dbFactory.CreateDbContext();
var repo = new TaskRepository(ctx);
await repo.UpdateAgentSettingsAsync(
dto.TaskId,
Nullify(dto.Model),
Nullify(dto.SystemPrompt),
Nullify(dto.AgentPath));
await _broadcaster.TaskUpdated(dto.TaskId);
}
private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s;
} }

View File

@@ -0,0 +1,47 @@
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
using Xunit;
namespace ClaudeDo.Worker.Tests.Hub;
public sealed class AgentSettingsHubTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDo.Data.ClaudeDoDbContext _ctx;
private readonly ListRepository _repo;
public AgentSettingsHubTests()
{
_ctx = _db.CreateContext();
_repo = new ListRepository(_ctx);
}
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
}
[Fact]
public async Task UpdateListConfig_AllNull_DeletesRow()
{
var listId = Guid.NewGuid().ToString();
await _repo.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
await _repo.SetConfigAsync(new ListConfigEntity
{
ListId = listId, Model = "opus", SystemPrompt = null, AgentPath = null,
});
string? model = null, sp = null, ap = null;
if (model is null && sp is null && ap is null)
await _repo.DeleteConfigAsync(listId);
else
await _repo.SetConfigAsync(new ListConfigEntity
{
ListId = listId, Model = model, SystemPrompt = sp, AgentPath = ap,
});
Assert.Null(await _repo.GetConfigAsync(listId));
}
}

View File

@@ -0,0 +1,56 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
using Xunit;
namespace ClaudeDo.Worker.Tests.Repositories;
public sealed class ListRepositoryDeleteConfigTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly ListRepository _repo;
public ListRepositoryDeleteConfigTests()
{
_ctx = _db.CreateContext();
_repo = new ListRepository(_ctx);
}
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
}
[Fact]
public async Task DeleteConfigAsync_RemovesExistingRow()
{
var listId = Guid.NewGuid().ToString();
await _repo.AddAsync(new ListEntity
{
Id = listId, Name = "L", CreatedAt = DateTime.UtcNow,
});
await _repo.SetConfigAsync(new ListConfigEntity
{
ListId = listId,
Model = "opus",
SystemPrompt = "hello",
AgentPath = "/tmp/a.md",
});
var removed = await _repo.DeleteConfigAsync(listId);
Assert.True(removed);
Assert.Null(await _repo.GetConfigAsync(listId));
}
[Fact]
public async Task DeleteConfigAsync_ReturnsFalseWhenAbsent()
{
var removed = await _repo.DeleteConfigAsync(Guid.NewGuid().ToString());
Assert.False(removed);
}
}

View File

@@ -0,0 +1,75 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
using Xunit;
namespace ClaudeDo.Worker.Tests.Repositories;
public sealed class TaskRepositoryAgentSettingsTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _repo;
public TaskRepositoryAgentSettingsTests()
{
_ctx = _db.CreateContext();
_repo = new TaskRepository(_ctx);
}
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
}
private async Task<string> SeedTaskAsync()
{
using var ctx = _db.CreateContext();
var listId = Guid.NewGuid().ToString();
var taskId = Guid.NewGuid().ToString();
await new ListRepository(ctx).AddAsync(new ListEntity
{
Id = listId, Name = "L", CreatedAt = DateTime.UtcNow,
});
await new TaskRepository(ctx).AddAsync(new TaskEntity
{
Id = taskId, ListId = listId, Title = "T", CreatedAt = DateTime.UtcNow,
});
return taskId;
}
[Fact]
public async Task UpdateAgentSettingsAsync_SetsAllThreeFields()
{
var taskId = await SeedTaskAsync();
await _repo.UpdateAgentSettingsAsync(taskId, "opus", "system!", "/tmp/a.md");
var entity = await _repo.GetByIdAsync(taskId);
Assert.NotNull(entity);
Assert.Equal("opus", entity!.Model);
Assert.Equal("system!", entity.SystemPrompt);
Assert.Equal("/tmp/a.md", entity.AgentPath);
}
[Fact]
public async Task UpdateAgentSettingsAsync_NullsClearColumns()
{
var taskId = await SeedTaskAsync();
using (var ctx = _db.CreateContext())
{
await new TaskRepository(ctx).UpdateAgentSettingsAsync(taskId, "opus", "s", "/a.md");
}
await _repo.UpdateAgentSettingsAsync(taskId, null, null, null);
var entity = await _repo.GetByIdAsync(taskId);
Assert.NotNull(entity);
Assert.Null(entity!.Model);
Assert.Null(entity.SystemPrompt);
Assert.Null(entity.AgentPath);
}
}

View File

@@ -227,6 +227,96 @@ public sealed class TaskRepositoryTests : IDisposable
Assert.Null(after.Result); Assert.Null(after.Result);
} }
[Fact]
public async Task AddAsync_AssignsDense_PerList_SortOrder()
{
var listA = await CreateListAsync();
var listB = await CreateListAsync();
var a0 = MakeTask(listA); await _tasks.AddAsync(a0);
var a1 = MakeTask(listA); await _tasks.AddAsync(a1);
var b0 = MakeTask(listB); await _tasks.AddAsync(b0);
var a2 = MakeTask(listA); await _tasks.AddAsync(a2);
var reloadA0 = await _tasks.GetByIdAsync(a0.Id);
var reloadA1 = await _tasks.GetByIdAsync(a1.Id);
var reloadA2 = await _tasks.GetByIdAsync(a2.Id);
var reloadB0 = await _tasks.GetByIdAsync(b0.Id);
Assert.Equal(0, reloadA0!.SortOrder);
Assert.Equal(1, reloadA1!.SortOrder);
Assert.Equal(2, reloadA2!.SortOrder);
Assert.Equal(0, reloadB0!.SortOrder);
}
[Fact]
public async Task GetByListIdAsync_OrdersBy_SortOrder()
{
var listId = await CreateListAsync();
var t0 = MakeTask(listId); await _tasks.AddAsync(t0);
var t1 = MakeTask(listId); await _tasks.AddAsync(t1);
var t2 = MakeTask(listId); await _tasks.AddAsync(t2);
await _tasks.ReorderAsync(listId, new[] { t2.Id, t0.Id, t1.Id });
var list = await _tasks.GetByListIdAsync(listId);
Assert.Equal(new[] { t2.Id, t0.Id, t1.Id }, list.Select(t => t.Id).ToArray());
}
[Fact]
public async Task ReorderAsync_Renumbers_Dense()
{
var listId = await CreateListAsync();
var t0 = MakeTask(listId); await _tasks.AddAsync(t0);
var t1 = MakeTask(listId); await _tasks.AddAsync(t1);
var t2 = MakeTask(listId); await _tasks.AddAsync(t2);
await _tasks.ReorderAsync(listId, new[] { t1.Id, t2.Id, t0.Id });
var r0 = await _tasks.GetByIdAsync(t0.Id);
var r1 = await _tasks.GetByIdAsync(t1.Id);
var r2 = await _tasks.GetByIdAsync(t2.Id);
Assert.Equal(2, r0!.SortOrder);
Assert.Equal(0, r1!.SortOrder);
Assert.Equal(1, r2!.SortOrder);
}
[Fact]
public async Task ReorderAsync_IgnoresIds_FromOtherLists()
{
var listA = await CreateListAsync();
var listB = await CreateListAsync();
var a0 = MakeTask(listA); await _tasks.AddAsync(a0);
var b0 = MakeTask(listB); await _tasks.AddAsync(b0);
// b0 does not belong to listA and should not be renumbered there.
await _tasks.ReorderAsync(listA, new[] { b0.Id, a0.Id });
var reloadB = await _tasks.GetByIdAsync(b0.Id);
Assert.Equal(0, reloadB!.SortOrder);
}
[Fact]
public async Task GetNextQueuedAgentTaskAsync_Picks_ByUserSortOrder()
{
var listId = await CreateListAsync();
var agentTagId = await _tags.GetOrCreateAsync("agent");
await _lists.AddTagAsync(listId, agentTagId);
// created in order first, second; then user reorders to put second on top.
var first = MakeTask(listId, createdAt: DateTime.UtcNow.AddMinutes(-10));
var second = MakeTask(listId, createdAt: DateTime.UtcNow);
await _tasks.AddAsync(first);
await _tasks.AddAsync(second);
await _tasks.ReorderAsync(listId, new[] { second.Id, first.Id });
var picked = await _tasks.GetNextQueuedAgentTaskAsync(DateTime.UtcNow);
Assert.NotNull(picked);
Assert.Equal(second.Id, picked!.Id);
}
[Fact] [Fact]
public async Task GetEffectiveTagsAsync_Returns_Union_Of_ListTags_And_TaskTags() public async Task GetEffectiveTagsAsync_Returns_Union_Of_ListTags_And_TaskTags()
{ {