Compare commits
21 Commits
b378fbf550
...
a4e313dbad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4e313dbad | ||
|
|
7de5510735 | ||
|
|
5e54275842 | ||
|
|
6ac88235a7 | ||
|
|
c599fdcb8c | ||
|
|
b0b15e474e | ||
|
|
839f862b7d | ||
|
|
2901a769d8 | ||
|
|
e74e7eecf4 | ||
|
|
bba577888b | ||
|
|
5784dbee94 | ||
|
|
5348220e60 | ||
|
|
cd0b95ef9a | ||
|
|
fc1cfe59ec | ||
|
|
7c312161bb | ||
|
|
480eb0817a | ||
|
|
1b94fa5c44 | ||
|
|
02464b7f89 | ||
|
|
68f461d0e1 | ||
|
|
cfb410dd4d | ||
|
|
883c98dc0a |
37
README.md
37
README.md
@@ -16,29 +16,29 @@ Two-process system communicating over SignalR:
|
||||
| **ClaudeDo.Worker** | ASP.NET Core hosted service, task queue, Claude CLI runner |
|
||||
|
||||
```
|
||||
┌──────────────┐ SignalR ┌──────────────┐
|
||||
│ ClaudeDo.App│◄──────────►│ClaudeDo.Worker│
|
||||
│ (Avalonia) │ 127.0.0.1 │ (ASP.NET) │
|
||||
│ │ :47821 │ │
|
||||
│ ┌──────────┐│ │ ┌──────────┐ │
|
||||
│ │ Ui ││ │ │ TaskQueue│ │
|
||||
│ │(ViewModels)│ │ │ Claude CLI│ │
|
||||
│ └──────────┘│ │ └──────────┘ │
|
||||
└──────┬───────┘ └──────┬───────┘
|
||||
│ │
|
||||
└───────────┬───────────────┘
|
||||
│
|
||||
┌──────┴──────┐
|
||||
│ ClaudeDo.Data│
|
||||
│ (SQLite) │
|
||||
└─────────────┘
|
||||
┌────────────────┐ SignalR ┌────────────────┐
|
||||
│ ClaudeDo.App │◄───────────►│ ClaudeDo.Worker │
|
||||
│ (Avalonia) │ 127.0.0.1 │ (ASP.NET Core) │
|
||||
│ │ :47821 │ │
|
||||
│ ┌────────────┐│ │ ┌────────────┐ │
|
||||
│ │ Ui ││ │ │ TaskQueue │ │
|
||||
│ │(ViewModels)││ │ │ Claude CLI │ │
|
||||
│ └────────────┘│ │ └────────────┘ │
|
||||
└───────┬────────┘ └───────┬────────┘
|
||||
│ │
|
||||
└──────────────┬───────────────┘
|
||||
│
|
||||
┌───────┴───────┐
|
||||
│ ClaudeDo.Data │
|
||||
│ (SQLite) │
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- .NET 8.0
|
||||
- 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
|
||||
- CommunityToolkit.Mvvm for source-generated MVVM
|
||||
- Git worktrees for task isolation
|
||||
@@ -53,7 +53,8 @@ Two-process system communicating over SignalR:
|
||||
|
||||
```bash
|
||||
# Build
|
||||
dotnet build ClaudeDo.slnx
|
||||
dotnet build src/ClaudeDo.App
|
||||
dotnet build src/ClaudeDo.Worker
|
||||
|
||||
# Run tests
|
||||
dotnet test tests/ClaudeDo.Worker.Tests
|
||||
|
||||
1223
docs/superpowers/plans/2026-04-22-agent-settings-ui.md
Normal file
1223
docs/superpowers/plans/2026-04-22-agent-settings-ui.md
Normal file
File diff suppressed because it is too large
Load Diff
162
docs/superpowers/specs/2026-04-22-agent-settings-ui-design.md
Normal file
162
docs/superpowers/specs/2026-04-22-agent-settings-ui-design.md
Normal 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.
|
||||
@@ -79,12 +79,14 @@ sealed class Program
|
||||
sc.AddTransient<WorktreeModalViewModel>();
|
||||
sc.AddTransient<SettingsModalViewModel>();
|
||||
sc.AddTransient<MergeModalViewModel>();
|
||||
sc.AddTransient<ListSettingsModalViewModel>();
|
||||
|
||||
// Islands shell VMs
|
||||
sc.AddSingleton<ListsIslandViewModel>(sp =>
|
||||
new ListsIslandViewModel(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp));
|
||||
sp,
|
||||
sp.GetRequiredService<WorkerClient>()));
|
||||
sc.AddSingleton<TasksIslandViewModel>(sp =>
|
||||
new TasksIslandViewModel(sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>()));
|
||||
sc.AddSingleton<DetailsIslandViewModel>(sp =>
|
||||
|
||||
@@ -4,19 +4,23 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
||||
|
||||
## 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
|
||||
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath (all nullable)
|
||||
- **TagEntity** — Id (autoincrement), Name (unique)
|
||||
- **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
|
||||
|
||||
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`
|
||||
- **ListRepository** — CRUD, tag junction management
|
||||
- **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, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config`
|
||||
- **TagRepository** — `GetOrCreateAsync` (idempotent)
|
||||
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`
|
||||
- **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
|
||||
|
||||
## Infrastructure
|
||||
|
||||
@@ -31,7 +35,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. Exception: `T
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
||||
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.Notes).HasColumnName("notes");
|
||||
builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
||||
|
||||
builder.HasOne(t => t.List)
|
||||
.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.Status).HasDatabaseName("idx_tasks_status");
|
||||
builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,6 +281,12 @@ namespace ClaudeDo.Data.Migrations
|
||||
.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");
|
||||
@@ -307,6 +313,9 @@ namespace ClaudeDo.Data.Migrations
|
||||
b.HasIndex("Status")
|
||||
.HasDatabaseName("idx_tasks_status");
|
||||
|
||||
b.HasIndex("ListId", "SortOrder")
|
||||
.HasDatabaseName("idx_tasks_list_sort");
|
||||
|
||||
b.ToTable("tasks", (string)null);
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ public sealed class TaskEntity
|
||||
public bool IsStarred { get; set; }
|
||||
public bool IsMyDay { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
public ListEntity List { get; set; } = null!;
|
||||
|
||||
@@ -88,4 +88,12 @@ public sealed class ListRepository
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,13 @@ public sealed class TaskRepository
|
||||
|
||||
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);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
@@ -38,10 +45,32 @@ public sealed class TaskRepository
|
||||
{
|
||||
return await _context.Tasks
|
||||
.Where(t => t.ListId == listId)
|
||||
.OrderBy(t => t.CreatedAt)
|
||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||
.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.
|
||||
public Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default)
|
||||
=> GetByListIdAsync(listId, ct);
|
||||
@@ -111,6 +140,25 @@ public sealed class TaskRepository
|
||||
|
||||
#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
|
||||
|
||||
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'
|
||||
)
|
||||
)
|
||||
ORDER BY t.created_at ASC
|
||||
ORDER BY t.sort_order ASC, t.created_at ASC
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING *
|
||||
|
||||
@@ -17,6 +17,8 @@ MVVM with CommunityToolkit.Mvvm source generators:
|
||||
- **TaskEditorView** — Modal dialog for task create/edit
|
||||
- **ListEditorView** — Modal dialog for list create/edit
|
||||
- **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`).
|
||||
|
||||
@@ -31,7 +33,7 @@ All views use compiled bindings (`x:DataType`).
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -272,6 +272,18 @@
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</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 -->
|
||||
|
||||
@@ -45,6 +45,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
||||
public event Action<string>? TaskUpdatedEvent;
|
||||
public event Action<string>? WorktreeUpdatedEvent;
|
||||
public event Action<string>? RunNowRequestedEvent;
|
||||
public event Action<string>? ListUpdatedEvent;
|
||||
|
||||
public WorkerClient(string signalRUrl)
|
||||
{
|
||||
@@ -110,6 +111,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId));
|
||||
});
|
||||
|
||||
_hub.On<string>("ListUpdated", listId =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => ListUpdatedEvent?.Invoke(listId));
|
||||
});
|
||||
}
|
||||
|
||||
public Task StartAsync()
|
||||
@@ -271,6 +277,26 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
||||
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()
|
||||
{
|
||||
try
|
||||
@@ -318,3 +344,7 @@ public sealed record WorktreeCleanupDto(int Removed);
|
||||
public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
|
||||
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
||||
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);
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Text;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Helpers;
|
||||
using ClaudeDo.Ui.Services;
|
||||
@@ -53,9 +54,32 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(IsRunning));
|
||||
OnPropertyChanged(nameof(IsDone));
|
||||
OnPropertyChanged(nameof(IsFailed));
|
||||
OnPropertyChanged(nameof(IsAgentSectionEnabled));
|
||||
ShowFailedActions = value == "Failed";
|
||||
}
|
||||
[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? _worktreeBaseCommit;
|
||||
[ObservableProperty] private string? _worktreeStateLabel;
|
||||
@@ -86,6 +110,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
public ObservableCollection<LogLineViewModel> Log { get; } = new();
|
||||
public ObservableCollection<SubtaskRowViewModel> Subtasks { get; } = new();
|
||||
|
||||
[ObservableProperty] private string _newSubtaskTitle = "";
|
||||
|
||||
// Claude CLI stream-json parser + buffer for partial text deltas
|
||||
private readonly StreamLineFormatter _formatter = 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 });
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
_loadCts?.Cancel();
|
||||
@@ -237,6 +324,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
AgentStatusLabel = "Idle";
|
||||
LatestRunSessionId = null;
|
||||
ShowFailedActions = false;
|
||||
_suppressAgentSave = true;
|
||||
try
|
||||
{
|
||||
TaskModelSelection = "(inherit)";
|
||||
TaskSystemPrompt = "";
|
||||
TaskSelectedAgent = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppressAgentSave = false;
|
||||
}
|
||||
EffectiveModelHint = "";
|
||||
EffectiveSystemPromptHint = "";
|
||||
EffectiveAgentHint = "";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -266,6 +367,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
||||
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
|
||||
AgentStatusLabel = entity.Status.ToString();
|
||||
await LoadAgentSettingsAsync(entity, ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var runRepo = new TaskRunRepository(ctx);
|
||||
var latestRun = await runRepo.GetLatestByTaskIdAsync(row.Id, ct);
|
||||
@@ -386,6 +489,30 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
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]
|
||||
private async System.Threading.Tasks.Task SaveNotesAsync()
|
||||
{
|
||||
|
||||
@@ -9,6 +9,8 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
|
||||
public required ListKind Kind { get; init; }
|
||||
[ObservableProperty] private int _count;
|
||||
[ObservableProperty] private bool _isActive;
|
||||
[ObservableProperty] private string? _workingDir;
|
||||
[ObservableProperty] private string _defaultCommitType = "chore";
|
||||
public string? IconKey { get; init; }
|
||||
public string? DotColorKey { get; init; }
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -15,12 +16,14 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly IServiceProvider? _services;
|
||||
private readonly WorkerClient? _worker;
|
||||
|
||||
public event EventHandler? SelectionChanged;
|
||||
public event EventHandler? FocusSearchRequested;
|
||||
public void RequestFocusSearch() => FocusSearchRequested?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
public Func<SettingsModalViewModel, Task>? ShowSettingsModal { get; set; }
|
||||
public Func<ListSettingsModalViewModel, System.Threading.Tasks.Task>? ShowListSettingsModal { get; set; }
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenSettings()
|
||||
@@ -31,6 +34,16 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||
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> SmartLists { 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 UserInitials { get; }
|
||||
|
||||
public ListsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IServiceProvider? services = null)
|
||||
public ListsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IServiceProvider? services = null, WorkerClient? worker = null)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_services = services;
|
||||
_worker = worker;
|
||||
var parts = Environment.UserName.Split('.', '_', '-', ' ');
|
||||
UserInitials = parts.Length >= 2
|
||||
? $"{parts[0][0]}{parts[1][0]}".ToUpperInvariant()
|
||||
: Environment.UserName.Length >= 2
|
||||
? Environment.UserName[..2].ToUpperInvariant()
|
||||
: Environment.UserName.ToUpperInvariant();
|
||||
|
||||
if (_worker is not null)
|
||||
_worker.ListUpdatedEvent += id => _ = RefreshRowAsync(id);
|
||||
}
|
||||
|
||||
public async Task LoadAsync(CancellationToken ct = default)
|
||||
@@ -85,6 +102,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||
Kind = ListKind.User,
|
||||
IconKey = "Folder",
|
||||
DotColorKey = dotColors[idx % dotColors.Length],
|
||||
WorkingDir = l.WorkingDir,
|
||||
DefaultCommitType = l.DefaultCommitType,
|
||||
};
|
||||
Items.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);
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
[ObservableProperty] private DateTime? _scheduledFor;
|
||||
[ObservableProperty] private int _diffAdditions;
|
||||
[ObservableProperty] private int _diffDeletions;
|
||||
[ObservableProperty] private bool _dropHintAbove;
|
||||
[ObservableProperty] private bool _dropHintBelow;
|
||||
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}";
|
||||
|
||||
@@ -75,6 +75,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
var all = await db.Tasks
|
||||
.Include(t => t.List)
|
||||
.Include(t => t.Worktree)
|
||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
@@ -149,23 +150,83 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(NewTaskTitle) || _currentList?.Kind != ListKind.User) return;
|
||||
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
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
ListId = listId,
|
||||
Title = NewTaskTitle.Trim(),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
SortOrder = (maxSort ?? -1) + 1,
|
||||
};
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
db.Tasks.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
var row = TaskRowViewModel.FromEntity(entity);
|
||||
Items.Insert(0, row);
|
||||
Items.Add(row);
|
||||
Regroup();
|
||||
NewTaskTitle = "";
|
||||
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]
|
||||
private async Task ToggleDoneAsync(TaskRowViewModel row)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@
|
||||
BorderThickness="0,1,0,0"
|
||||
Padding="14,8">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<!-- Delete button -->
|
||||
<Button Grid.Column="0" Classes="icon-btn"
|
||||
Command="{Binding DeleteTaskCommand}"
|
||||
ToolTip.Tip="Delete task"
|
||||
@@ -20,14 +19,12 @@
|
||||
<PathIcon Data="{StaticResource Icon.Trash}" Width="14" Height="14"
|
||||
Foreground="{DynamicResource BloodBrush}"/>
|
||||
</Button>
|
||||
<!-- Created date -->
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding Task.CreatedAtFormatted}"
|
||||
FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
||||
Foreground="{DynamicResource TextFaintBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
<!-- Close button -->
|
||||
<Button Grid.Column="2" Classes="icon-btn"
|
||||
Command="{Binding CloseDetailsCommand}"
|
||||
ToolTip.Tip="Close"
|
||||
@@ -37,30 +34,76 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ── Header ── -->
|
||||
<!-- ── Header (sticky top): eyebrow · title · gear (agent-settings flyout) ── -->
|
||||
<Border DockPanel.Dock="Top" Classes="island-header">
|
||||
<!-- Eyebrow row -->
|
||||
<StackPanel Spacing="0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,4">
|
||||
<Ellipse Width="5" Height="5" Fill="{DynamicResource AccentBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Classes="eyebrow" Text="LOGBOOK" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding TaskIdBadge}"
|
||||
FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
||||
Foreground="{DynamicResource TextFaintBrush}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="8,0,0,0"/>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,4">
|
||||
<Ellipse Width="5" Height="5" Fill="{DynamicResource AccentBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Classes="eyebrow" Text="LOGBOOK" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding TaskIdBadge}"
|
||||
FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
||||
Foreground="{DynamicResource TextFaintBrush}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="8,0,0,0"/>
|
||||
</StackPanel>
|
||||
<TextBox Text="{Binding EditableTitle, Mode=TwoWay}"
|
||||
FontSize="14" FontWeight="Medium"
|
||||
BorderThickness="0" Background="Transparent"
|
||||
Foreground="{DynamicResource TextBrush}"
|
||||
Padding="0"/>
|
||||
</StackPanel>
|
||||
<!-- Editable title (reduced size) -->
|
||||
<TextBox Text="{Binding EditableTitle, Mode=TwoWay}"
|
||||
FontSize="14" FontWeight="Medium"
|
||||
BorderThickness="0" Background="Transparent"
|
||||
Foreground="{DynamicResource TextBrush}"
|
||||
Padding="0"/>
|
||||
</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>
|
||||
|
||||
<!-- ── Task strip row: check + title display + star ── -->
|
||||
<!-- ── Task strip row (sticky top): check + title + star ── -->
|
||||
<Border DockPanel.Dock="Top"
|
||||
Padding="18,10,18,10"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
@@ -88,26 +131,32 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ── Main body: agent strip (auto) · terminal (flex) · steps+notes (auto/capped) ── -->
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<!-- ── Agent status strip (sticky, above metadata footer) ── -->
|
||||
<islands:AgentStripView DockPanel.Dock="Bottom"/>
|
||||
|
||||
<!-- Agent strip -->
|
||||
<islands:AgentStripView Grid.Row="0"/>
|
||||
<!-- ── Scrollable body: steps + terminal ── -->
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="0">
|
||||
|
||||
<!-- 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">
|
||||
|
||||
<!-- Subtasks section -->
|
||||
<StackPanel Margin="18,12,18,0"
|
||||
IsVisible="{Binding Subtasks.Count}">
|
||||
<TextBlock Classes="section-label" Text="STEPS"
|
||||
Margin="0,0,0,6"/>
|
||||
<ItemsControl ItemsSource="{Binding Subtasks}">
|
||||
<!-- Steps section -->
|
||||
<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}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:SubtaskRowViewModel">
|
||||
<Border Classes="subtask-row"
|
||||
@@ -133,25 +182,13 @@
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Notes section -->
|
||||
<StackPanel Margin="18,12,18,12">
|
||||
<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>
|
||||
<!-- Session terminal — auto-sizes to output, scrolls internally after MaxHeight -->
|
||||
<islands:SessionTerminalView MaxHeight="420"/>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
</StackPanel>
|
||||
<!-- More button -->
|
||||
<Button Grid.Column="2" Classes="icon-btn" VerticalAlignment="Center"
|
||||
Command="{Binding OpenSettingsCommand}"
|
||||
Command="{Binding OpenListSettingsCommand}"
|
||||
ToolTip.Tip="Settings">
|
||||
<PathIcon Data="{StaticResource Icon.MoreHorizontal}"
|
||||
Width="14" Height="14"
|
||||
@@ -130,9 +130,16 @@
|
||||
<DataTemplate DataType="vm:ListNavItemViewModel">
|
||||
<Border Classes="list-item" Classes.active="{Binding IsActive}"
|
||||
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 -->
|
||||
<Border Grid.Column="0" Grid.ColumnSpan="3"
|
||||
<Border Grid.Column="0" Grid.ColumnSpan="4"
|
||||
Background="Transparent"
|
||||
CornerRadius="8" IsHitTestVisible="False"
|
||||
IsVisible="{Binding IsActive}">
|
||||
@@ -158,6 +165,18 @@
|
||||
FontFamily="{DynamicResource MonoFamily}" FontSize="10"
|
||||
Foreground="{DynamicResource TextFaintBrush}"
|
||||
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>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -17,6 +17,14 @@ public partial class ListsIslandView : UserControl
|
||||
{
|
||||
vm.FocusSearchRequested += (_, _) => SearchBox.Focus();
|
||||
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);
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
<!-- ── Log output ── -->
|
||||
<ScrollViewer Name="LogScroll" VerticalScrollBarVisibility="Auto"
|
||||
Padding="10,8,10,4">
|
||||
Padding="10,8,10,12">
|
||||
<ItemsControl ItemsSource="{Binding Log}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:LogLineViewModel">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Specialized;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Islands;
|
||||
@@ -17,7 +18,7 @@ public partial class SessionTerminalView : UserControl
|
||||
|
||||
private void OnLogChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.Action == NotifyCollectionChangedAction.Add)
|
||||
LogScroll.ScrollToEnd();
|
||||
if (e.Action != NotifyCollectionChangedAction.Add) return;
|
||||
Dispatcher.UIThread.Post(() => LogScroll.ScrollToEnd(), DispatcherPriority.Background);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,23 @@
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
||||
x:Class="ClaudeDo.Ui.Views.Islands.TaskRowView"
|
||||
x:DataType="vm:TaskRowViewModel">
|
||||
<Border Classes="task-row"
|
||||
Classes.selected="{Binding IsSelected}"
|
||||
Classes.done="{Binding Done}">
|
||||
<Grid ColumnDefinitions="4,32,*,32" Margin="6,8,10,8">
|
||||
<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.done="{Binding Done}">
|
||||
<Grid ColumnDefinitions="4,32,*,32" Margin="6,8,10,8">
|
||||
|
||||
<!-- Left accent bar (visible when selected) -->
|
||||
<Border Grid.Column="0" Classes="task-row-accent"
|
||||
@@ -108,6 +121,13 @@
|
||||
CommandParameter="{Binding}">
|
||||
<PathIcon Width="14" Height="14" Data="{StaticResource Icon.Star}"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
</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>
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -80,7 +80,13 @@
|
||||
<Button Classes="flat" HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
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/>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
@@ -99,7 +105,13 @@
|
||||
<Button Classes="flat" HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
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/>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
@@ -123,7 +135,13 @@
|
||||
<Button Classes="flat" HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
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/>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -1,17 +1,150 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.VisualTree;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Islands;
|
||||
|
||||
public partial class TasksIslandView : UserControl
|
||||
{
|
||||
private static readonly DataFormat<string> TaskRowFormat =
|
||||
DataFormat.CreateStringApplicationFormat("claudedo-task-row");
|
||||
|
||||
public TasksIslandView()
|
||||
{
|
||||
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 += (_, _) =>
|
||||
{
|
||||
if (DataContext is TasksIslandViewModel vm)
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
80
src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml
Normal file
80
src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml
Normal 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>
|
||||
31
src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml.cs
Normal file
31
src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,9 +45,9 @@ Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ public sealed class HubBroadcaster
|
||||
public Task TaskUpdated(string 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) =>
|
||||
_hub.Clients.All.SendAsync("RunCreated", taskId, runNumber, isRetry);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ public record WorktreeCleanupDto(int Removed);
|
||||
public record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
|
||||
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
||||
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
|
||||
{
|
||||
@@ -205,4 +209,70 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
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;
|
||||
}
|
||||
|
||||
47
tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs
Normal file
47
tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -227,6 +227,96 @@ public sealed class TaskRepositoryTests : IDisposable
|
||||
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]
|
||||
public async Task GetEffectiveTagsAsync_Returns_Union_Of_ListTags_And_TaskTags()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user