9 Commits

Author SHA1 Message Date
66843d242b Merge branch 'main' into feat/ui-improvements 2026-04-15 09:28:18 +00:00
6afe5959ca Merge pull request 'feat/release-workflow' (#2) from feat/release-workflow into main
Reviewed-on: #2
2026-04-15 09:27:53 +00:00
b623651a5d Merge branch 'main' into feat/release-workflow 2026-04-15 09:27:21 +00:00
Mika Kuns
6b1b920149 feat(installer): download-mode rewrite + Gitea Releases pipeline
Rewrites ClaudeDo.Installer to fetch prebuilt binaries from
git.kuns.dev/releases/ClaudeDo instead of building from source.

- Async InstallModeDetector: FreshInstall / Update / Config
- DownloadAndExtractStep with SHA256 verify + scratch-dir extract
- UninstallRunner: stop-service / delete / full ~/.todo-app removal
  with path guard + partial-failure reporting
- Config view: Save / Repair / Uninstall buttons
- Self-contained single-file publish for the installer itself
- 29 xUnit tests in new ClaudeDo.Installer.Tests project

Spec: docs/superpowers/specs/2026-04-15-installer-download-mode-design.md
Plan: docs/superpowers/plans/2026-04-15-installer-download-mode.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:22:01 +02:00
Mika Kuns
9a407bde83 feat(ui): agent config inline in detail panel, file picker, subtask UI
TaskDetailView now edits Model / SystemPrompt / Agent inline (LostFocus
save), matching the modal editor. Both TaskEditorView and TaskDetailView
gain a Browse button that opens a .md file picker — external agent
paths are preserved on reload via a synthetic AgentInfo entry. Both
views also render the per-task subtask checklist (CheckBox + TextBox +
remove), with diff-on-save in the editor and inline-save in the detail
panel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:20:17 +02:00
Mika Kuns
8c051d8f62 feat(data): add subtasks table, repository and prompt integration
Per-task checklist backend: subtasks table with CASCADE delete,
SubtaskEntity + SubtaskRepository (connection-per-op, async), DI
registration in App and Worker, TaskRunner composes a '## Sub-Tasks'
markdown block into the Claude prompt when subtasks exist.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:19:54 +02:00
Mika Kuns
8577c55685 feat(ui): remove MaxWidth on main columns to use full window width
Lists (320px) and Detail (500px) borders no longer cap the 3-column
grid — star-sizing (1*:2*:1.5*) now fills the window, reducing the
dead whitespace between columns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:19:41 +02:00
7119c8474e ci(release): use system zip now that it's installed on the runner 2026-04-15 06:33:50 +00:00
aea09098e6 feat(ci): add Gitea Actions release workflow
Builds App + Worker + Installer for win-x64 self-contained on v* tag push,
bundles into ClaudeDo-<version>-win-x64.zip (app + worker),
renames installer to ClaudeDo.Installer-<version>.exe,
writes sha256 checksums.txt, then creates a Gitea Release on
releases/ClaudeDo and attaches all three assets.

Uses the workflow-scoped GITEA_TOKEN; no PAT required.
Host-mode runner (ubuntu-latest:host) with installed .NET 8 at
/home/mika/.dotnet. Uses python3 -m zipfile because the host
runner has no zip CLI, and git clone instead of actions/checkout
because DEFAULT_ACTIONS_URL=self has no local checkout mirror.
2026-04-15 06:31:50 +00:00
17 changed files with 704 additions and 23 deletions

View File

@@ -0,0 +1,171 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
env:
DOTNET_ROOT: /home/mika/.dotnet
GITEA_API: https://git.kuns.dev/api/v1
REPO: releases/ClaudeDo
steps:
- name: Resolve version
id: ver
run: |
set -euo pipefail
TAG="${{ github.ref_name }}"
VERSION="${TAG#v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Building version: $VERSION (tag: $TAG)"
- name: Prepare workspace
id: ws
run: |
set -euo pipefail
WORK="$(mktemp -d -t claudedo-release-XXXXXX)"
echo "dir=$WORK" >> "$GITHUB_OUTPUT"
echo "Workspace: $WORK"
- name: Checkout tag
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
WORK: ${{ steps.ws.outputs.dir }}
TAG: ${{ steps.ver.outputs.tag }}
run: |
set -euo pipefail
git clone --depth 1 --branch "$TAG" \
"https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" \
"$WORK/src"
git -C "$WORK/src" log -1 --oneline
- name: Publish ClaudeDo.App (win-x64, self-contained)
env:
WORK: ${{ steps.ws.outputs.dir }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
export PATH="$DOTNET_ROOT:$PATH"
cd "$WORK/src"
dotnet publish src/ClaudeDo.App/ClaudeDo.App.csproj \
-c Release -r win-x64 --self-contained true \
/p:Version=$VERSION -o out/app
- name: Publish ClaudeDo.Worker (win-x64, self-contained)
env:
WORK: ${{ steps.ws.outputs.dir }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
export PATH="$DOTNET_ROOT:$PATH"
cd "$WORK/src"
dotnet publish src/ClaudeDo.Worker/ClaudeDo.Worker.csproj \
-c Release -r win-x64 --self-contained true \
/p:Version=$VERSION -o out/worker
- name: Publish ClaudeDo.Installer (win-x64, single-file)
env:
WORK: ${{ steps.ws.outputs.dir }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
export PATH="$DOTNET_ROOT:$PATH"
cd "$WORK/src"
dotnet publish src/ClaudeDo.Installer/ClaudeDo.Installer.csproj \
-c Release -r win-x64 --self-contained true \
/p:Version=$VERSION /p:PublishSingleFile=true \
-o out/installer
- name: Package assets
env:
WORK: ${{ steps.ws.outputs.dir }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
cd "$WORK/src"
mkdir -p assets
# 1) App + Worker bundle (top-level dirs /app and /worker)
rm -rf bundle
mkdir -p bundle
cp -r out/app bundle/app
cp -r out/worker bundle/worker
ZIP_NAME="ClaudeDo-${VERSION}-win-x64.zip"
( cd bundle && zip -r -q "../assets/${ZIP_NAME}" app worker )
# 2) Installer single-file exe (renamed)
INSTALLER_EXE=$(ls out/installer/*.exe | head -n 1)
if [ -z "$INSTALLER_EXE" ]; then
echo "::error::No .exe produced by installer publish" >&2
exit 1
fi
cp "$INSTALLER_EXE" "assets/ClaudeDo.Installer-${VERSION}.exe"
# 3) Checksums (sha256, relative filenames)
( cd assets && sha256sum \
"ClaudeDo-${VERSION}-win-x64.zip" \
"ClaudeDo.Installer-${VERSION}.exe" \
> checksums.txt )
echo "--- assets ---"
ls -la assets
- name: Create Gitea Release
id: release
env:
WORK: ${{ steps.ws.outputs.dir }}
TAG: ${{ steps.ver.outputs.tag }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
BODY=$(jq -n \
--arg tag "$TAG" \
--arg name "$TAG" \
'{tag_name:$tag, name:$name, body:"", draft:false, prerelease:false, target_commitish:"main"}')
RESP=$(curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$BODY" \
"${GITEA_API}/repos/${REPO}/releases")
RELEASE_ID=$(echo "$RESP" | jq -r '.id // empty')
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
echo "::error::Release creation failed" >&2
echo "$RESP" >&2
exit 1
fi
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
echo "Created release id=$RELEASE_ID for tag=$TAG"
- name: Upload release assets
env:
WORK: ${{ steps.ws.outputs.dir }}
VERSION: ${{ steps.ver.outputs.version }}
RELEASE_ID: ${{ steps.release.outputs.release_id }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
cd "$WORK/src/assets"
for f in \
"ClaudeDo-${VERSION}-win-x64.zip" \
"ClaudeDo.Installer-${VERSION}.exe" \
"checksums.txt"
do
echo "Uploading: $f"
curl -sS --fail-with-body -X POST \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@${f}" \
"${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${f}" \
> /dev/null
done
echo "All assets uploaded."
- name: Cleanup workspace
if: always()
env:
WORK: ${{ steps.ws.outputs.dir }}
run: |
rm -rf "$WORK" || true

View File

@@ -85,6 +85,16 @@ CREATE TABLE IF NOT EXISTS task_runs (
CREATE INDEX IF NOT EXISTS idx_task_runs_task_id ON task_runs(task_id); CREATE INDEX IF NOT EXISTS idx_task_runs_task_id ON task_runs(task_id);
CREATE TABLE IF NOT EXISTS subtasks (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
title TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0,
order_num INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_subtasks_task_id ON subtasks(task_id);
-- Seed: minimal tag set (ignored if already present) -- Seed: minimal tag set (ignored if already present)
INSERT OR IGNORE INTO tags (name) VALUES ('agent'); INSERT OR IGNORE INTO tags (name) VALUES ('agent');
INSERT OR IGNORE INTO tags (name) VALUES ('manual'); INSERT OR IGNORE INTO tags (name) VALUES ('manual');

View File

@@ -49,6 +49,7 @@ sealed class Program
// Repositories // Repositories
sc.AddSingleton<ListRepository>(); sc.AddSingleton<ListRepository>();
sc.AddSingleton<TaskRepository>(); sc.AddSingleton<TaskRepository>();
sc.AddSingleton<SubtaskRepository>();
sc.AddSingleton<TagRepository>(); sc.AddSingleton<TagRepository>();
sc.AddSingleton<WorktreeRepository>(); sc.AddSingleton<WorktreeRepository>();
@@ -66,7 +67,8 @@ sealed class Program
sp.GetRequiredService<ListRepository>(), sp.GetRequiredService<ListRepository>(),
sp.GetRequiredService<GitService>(), sp.GetRequiredService<GitService>(),
sp.GetRequiredService<WorkerClient>(), sp.GetRequiredService<WorkerClient>(),
sp.GetRequiredService<TagRepository>())); sp.GetRequiredService<TagRepository>(),
sp.GetRequiredService<SubtaskRepository>()));
sc.AddSingleton<TaskListViewModel>(sp => sc.AddSingleton<TaskListViewModel>(sp =>
{ {
var taskRepo = sp.GetRequiredService<TaskRepository>(); var taskRepo = sp.GetRequiredService<TaskRepository>();

View File

@@ -0,0 +1,11 @@
namespace ClaudeDo.Data.Models;
public sealed class SubtaskEntity
{
public required string Id { get; init; }
public required string TaskId { get; init; }
public required string Title { get; set; }
public bool Completed { get; set; }
public int OrderNum { get; set; }
public required DateTime CreatedAt { get; init; }
}

View File

@@ -0,0 +1,81 @@
using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite;
namespace ClaudeDo.Data.Repositories;
public sealed class SubtaskRepository
{
private readonly SqliteConnectionFactory _factory;
public SubtaskRepository(SqliteConnectionFactory factory) => _factory = factory;
public async Task<List<SubtaskEntity>> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, task_id, title, completed, order_num, created_at FROM subtasks WHERE task_id = @task_id ORDER BY order_num";
cmd.Parameters.AddWithValue("@task_id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<SubtaskEntity>();
while (await reader.ReadAsync(ct))
result.Add(ReadSubtask(reader));
return result;
}
public async Task AddAsync(SubtaskEntity entity, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO subtasks (id, task_id, title, completed, order_num, created_at)
VALUES (@id, @task_id, @title, @completed, @order_num, @created_at)
""";
BindSubtask(cmd, entity);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task UpdateAsync(SubtaskEntity entity, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE subtasks SET title = @title, completed = @completed, order_num = @order_num
WHERE id = @id
""";
cmd.Parameters.AddWithValue("@id", entity.Id);
cmd.Parameters.AddWithValue("@title", entity.Title);
cmd.Parameters.AddWithValue("@completed", entity.Completed ? 1 : 0);
cmd.Parameters.AddWithValue("@order_num", entity.OrderNum);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task DeleteAsync(string id, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM subtasks WHERE id = @id";
cmd.Parameters.AddWithValue("@id", id);
await cmd.ExecuteNonQueryAsync(ct);
}
private static void BindSubtask(SqliteCommand cmd, SubtaskEntity e)
{
cmd.Parameters.AddWithValue("@id", e.Id);
cmd.Parameters.AddWithValue("@task_id", e.TaskId);
cmd.Parameters.AddWithValue("@title", e.Title);
cmd.Parameters.AddWithValue("@completed", e.Completed ? 1 : 0);
cmd.Parameters.AddWithValue("@order_num", e.OrderNum);
cmd.Parameters.AddWithValue("@created_at", e.CreatedAt.ToString("o"));
}
private static SubtaskEntity ReadSubtask(SqliteDataReader r) => new()
{
Id = r.GetString(0),
TaskId = r.GetString(1),
Title = r.GetString(2),
Completed = r.GetInt64(3) != 0,
OrderNum = r.GetInt32(4),
CreatedAt = DateTime.Parse(r.GetString(5)),
};
}

View File

@@ -0,0 +1,23 @@
using ClaudeDo.Data.Models;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels;
public partial class SubtaskItemViewModel : ObservableObject
{
[ObservableProperty] private string _title = string.Empty;
[ObservableProperty] private bool _completed;
public string Id { get; set; } = string.Empty;
public string? OriginalTitle { get; set; }
public bool OriginalCompleted { get; set; }
public static SubtaskItemViewModel From(SubtaskEntity e) => new()
{
Id = e.Id,
Title = e.Title,
Completed = e.Completed,
OriginalTitle = e.Title,
OriginalCompleted = e.Completed,
};
}

View File

@@ -20,6 +20,7 @@ public partial class TaskDetailViewModel : ViewModelBase
private readonly GitService _git; private readonly GitService _git;
private readonly WorkerClient _worker; private readonly WorkerClient _worker;
private readonly TagRepository _tagRepo; private readonly TagRepository _tagRepo;
private readonly SubtaskRepository _subtaskRepo;
[ObservableProperty] private string _title = ""; [ObservableProperty] private string _title = "";
[ObservableProperty] private string? _description; [ObservableProperty] private string? _description;
@@ -28,9 +29,14 @@ public partial class TaskDetailViewModel : ViewModelBase
[ObservableProperty] private string _statusText = ""; [ObservableProperty] private string _statusText = "";
[ObservableProperty] private string _statusChoice = "Manual"; [ObservableProperty] private string _statusChoice = "Manual";
[ObservableProperty] private string _commitType = "chore"; [ObservableProperty] private string _commitType = "chore";
[ObservableProperty] private string _modelChoice = "(list default)";
[ObservableProperty] private string? _systemPromptOverride;
[ObservableProperty] private AgentInfo? _selectedAgent;
public List<AgentInfo> AvailableAgents { get; } = [];
public static string[] StatusChoices { get; } = ["Manual", "Queued", "Running", "Done", "Failed"]; public static string[] StatusChoices { get; } = ["Manual", "Queued", "Running", "Done", "Failed"];
public static string[] CommitTypes { get; } = ["feat", "fix", "refactor", "docs", "test", "chore", "ci", "perf", "style", "build"]; public static string[] CommitTypes { get; } = ["feat", "fix", "refactor", "docs", "test", "chore", "ci", "perf", "style", "build"];
public static string[] ModelChoices { get; } = ["(list default)", "Sonnet", "Opus", "Haiku"];
// Worktree // Worktree
[ObservableProperty] private bool _hasWorktree; [ObservableProperty] private bool _hasWorktree;
@@ -44,6 +50,7 @@ public partial class TaskDetailViewModel : ViewModelBase
private StreamLineFormatter _formatter = new(); private StreamLineFormatter _formatter = new();
public ObservableCollection<TagEntity> Tags { get; } = new(); public ObservableCollection<TagEntity> Tags { get; } = new();
[ObservableProperty] private string _newTagInput = ""; [ObservableProperty] private string _newTagInput = "";
public ObservableCollection<SubtaskItemViewModel> Subtasks { get; } = new();
private string? _taskId; private string? _taskId;
private string? _listId; private string? _listId;
@@ -52,7 +59,8 @@ public partial class TaskDetailViewModel : ViewModelBase
public event Action<string>? TaskChanged; public event Action<string>? TaskChanged;
public TaskDetailViewModel(TaskRepository taskRepo, WorktreeRepository worktreeRepo, public TaskDetailViewModel(TaskRepository taskRepo, WorktreeRepository worktreeRepo,
ListRepository listRepo, GitService git, WorkerClient worker, TagRepository tagRepo) ListRepository listRepo, GitService git, WorkerClient worker, TagRepository tagRepo,
SubtaskRepository subtaskRepo)
{ {
_taskRepo = taskRepo; _taskRepo = taskRepo;
_worktreeRepo = worktreeRepo; _worktreeRepo = worktreeRepo;
@@ -60,6 +68,7 @@ public partial class TaskDetailViewModel : ViewModelBase
_git = git; _git = git;
_worker = worker; _worker = worker;
_tagRepo = tagRepo; _tagRepo = tagRepo;
_subtaskRepo = subtaskRepo;
worker.TaskMessageEvent += OnTaskMessage; worker.TaskMessageEvent += OnTaskMessage;
worker.WorktreeUpdatedEvent += OnWorktreeUpdated; worker.WorktreeUpdatedEvent += OnWorktreeUpdated;
@@ -77,6 +86,13 @@ public partial class TaskDetailViewModel : ViewModelBase
var task = await _taskRepo.GetByIdAsync(taskId); var task = await _taskRepo.GetByIdAsync(taskId);
if (task is null) return; if (task is null) return;
if (AvailableAgents.Count == 0)
{
var agents = await _worker.GetAgentsAsync();
AvailableAgents.AddRange(agents);
OnPropertyChanged(nameof(AvailableAgents));
}
_isLoading = true; _isLoading = true;
try try
{ {
@@ -95,11 +111,39 @@ public partial class TaskDetailViewModel : ViewModelBase
StatusText = task.Status.ToString().ToLowerInvariant(); StatusText = task.Status.ToString().ToLowerInvariant();
StatusChoice = task.Status.ToString(); StatusChoice = task.Status.ToString();
CommitType = task.CommitType; CommitType = task.CommitType;
ModelChoice = task.Model is not null
? ListEditorViewModel.ModelIdToDisplay(task.Model)
: "(list default)";
SystemPromptOverride = task.SystemPrompt;
if (task.AgentPath is not null)
{
var match = AvailableAgents.FirstOrDefault(a => a.Path == task.AgentPath);
if (match is null)
{
match = new AgentInfo(Path.GetFileNameWithoutExtension(task.AgentPath), "(external)", task.AgentPath);
AvailableAgents.Add(match);
OnPropertyChanged(nameof(AvailableAgents));
}
SelectedAgent = match;
}
else
{
SelectedAgent = null;
}
Tags.Clear(); Tags.Clear();
var tags = await _taskRepo.GetTagsAsync(taskId); var tags = await _taskRepo.GetTagsAsync(taskId);
foreach (var tag in tags) foreach (var tag in tags)
Tags.Add(tag); Tags.Add(tag);
Subtasks.Clear();
var subtasks = await _subtaskRepo.GetByTaskIdAsync(taskId);
foreach (var s in subtasks)
{
var vm = SubtaskItemViewModel.From(s);
vm.PropertyChanged += OnSubtaskPropertyChanged;
Subtasks.Add(vm);
}
} }
finally finally
{ {
@@ -119,6 +163,11 @@ public partial class TaskDetailViewModel : ViewModelBase
entity.Title = Title; entity.Title = Title;
entity.Description = Description; entity.Description = Description;
entity.CommitType = CommitType; entity.CommitType = CommitType;
entity.Model = ModelChoice != "(list default)"
? ListEditorViewModel.ModelDisplayToId(ModelChoice)
: null;
entity.SystemPrompt = string.IsNullOrWhiteSpace(SystemPromptOverride) ? null : SystemPromptOverride.Trim();
entity.AgentPath = SelectedAgent?.Path;
if (Enum.TryParse<Data.Models.TaskStatus>(StatusChoice, true, out var status)) if (Enum.TryParse<Data.Models.TaskStatus>(StatusChoice, true, out var status))
entity.Status = status; entity.Status = status;
@@ -155,6 +204,61 @@ public partial class TaskDetailViewModel : ViewModelBase
TaskChanged?.Invoke(_taskId); TaskChanged?.Invoke(_taskId);
} }
[RelayCommand]
private async Task AddSubtask()
{
if (_taskId is null) return;
var entity = new SubtaskEntity
{
Id = Guid.NewGuid().ToString(),
TaskId = _taskId,
Title = "",
Completed = false,
OrderNum = Subtasks.Count,
CreatedAt = DateTime.UtcNow,
};
await _subtaskRepo.AddAsync(entity);
var vm = SubtaskItemViewModel.From(entity);
vm.PropertyChanged += OnSubtaskPropertyChanged;
Subtasks.Add(vm);
}
[RelayCommand]
private async Task RemoveSubtask(SubtaskItemViewModel item)
{
if (!string.IsNullOrEmpty(item.Id))
await _subtaskRepo.DeleteAsync(item.Id);
item.PropertyChanged -= OnSubtaskPropertyChanged;
Subtasks.Remove(item);
}
private async void OnSubtaskPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (_isLoading || sender is not SubtaskItemViewModel vm || string.IsNullOrEmpty(vm.Id)) return;
if (e.PropertyName is not (nameof(SubtaskItemViewModel.Title) or nameof(SubtaskItemViewModel.Completed))) return;
await _subtaskRepo.UpdateAsync(new SubtaskEntity
{
Id = vm.Id,
TaskId = _taskId ?? "",
Title = vm.Title,
Completed = vm.Completed,
OrderNum = Subtasks.IndexOf(vm),
CreatedAt = DateTime.UtcNow,
});
}
public void SetAgentFromPath(string path)
{
var existing = AvailableAgents.FirstOrDefault(a => a.Path == path);
if (existing is null)
{
existing = new AgentInfo(Path.GetFileNameWithoutExtension(path), "(external)", path);
AvailableAgents.Add(existing);
OnPropertyChanged(nameof(AvailableAgents));
}
SelectedAgent = existing;
}
public void Clear() public void Clear()
{ {
_taskId = null; _taskId = null;
@@ -169,8 +273,13 @@ public partial class TaskDetailViewModel : ViewModelBase
_formatter = new StreamLineFormatter(); _formatter = new StreamLineFormatter();
Tags.Clear(); Tags.Clear();
NewTagInput = ""; NewTagInput = "";
foreach (var s in Subtasks) s.PropertyChanged -= OnSubtaskPropertyChanged;
Subtasks.Clear();
StatusChoice = "Manual"; StatusChoice = "Manual";
CommitType = "chore"; CommitType = "chore";
ModelChoice = "(list default)";
SystemPromptOverride = null;
SelectedAgent = null;
} }
private async Task LoadWorktreeAsync(string taskId) private async Task LoadWorktreeAsync(string taskId)

View File

@@ -1,4 +1,7 @@
using System.Collections.ObjectModel;
using System.IO;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
@@ -8,6 +11,8 @@ namespace ClaudeDo.Ui.ViewModels;
public partial class TaskEditorViewModel : ViewModelBase public partial class TaskEditorViewModel : ViewModelBase
{ {
private readonly SubtaskRepository _subtaskRepo;
[ObservableProperty] private string _title = ""; [ObservableProperty] private string _title = "";
[ObservableProperty] private string? _description; [ObservableProperty] private string? _description;
[ObservableProperty] private string _commitType = "chore"; [ObservableProperty] private string _commitType = "chore";
@@ -18,6 +23,7 @@ public partial class TaskEditorViewModel : ViewModelBase
[ObservableProperty] private string? _systemPromptOverride; [ObservableProperty] private string? _systemPromptOverride;
[ObservableProperty] private AgentInfo? _selectedAgent; [ObservableProperty] private AgentInfo? _selectedAgent;
public List<AgentInfo> AvailableAgents { get; set; } = []; public List<AgentInfo> AvailableAgents { get; set; } = [];
public ObservableCollection<SubtaskItemViewModel> Subtasks { get; } = new();
private string? _editId; private string? _editId;
private string _listId = ""; private string _listId = "";
@@ -34,11 +40,28 @@ public partial class TaskEditorViewModel : ViewModelBase
public static string[] StatusChoices { get; } = public static string[] StatusChoices { get; } =
["manual", "queued"]; ["manual", "queued"];
public TaskEditorViewModel(SubtaskRepository subtaskRepo)
{
_subtaskRepo = subtaskRepo;
}
public async Task LoadAgentsAsync(WorkerClient worker) public async Task LoadAgentsAsync(WorkerClient worker)
{ {
AvailableAgents = await worker.GetAgentsAsync(); AvailableAgents = await worker.GetAgentsAsync();
} }
public void SetAgentFromPath(string path)
{
var existing = AvailableAgents.FirstOrDefault(a => a.Path == path);
if (existing is null)
{
existing = new AgentInfo(Path.GetFileNameWithoutExtension(path), "(external)", path);
AvailableAgents.Add(existing);
OnPropertyChanged(nameof(AvailableAgents));
}
SelectedAgent = existing;
}
public IReadOnlyList<string> SelectedTagNames => public IReadOnlyList<string> SelectedTagNames =>
TagsInput.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) TagsInput.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct() .Distinct()
@@ -51,8 +74,54 @@ public partial class TaskEditorViewModel : ViewModelBase
_createdAt = DateTime.UtcNow; _createdAt = DateTime.UtcNow;
CommitType = defaultCommitType; CommitType = defaultCommitType;
WindowTitle = "New Task"; WindowTitle = "New Task";
Subtasks.Clear();
} }
public async Task InitForEditAsync(TaskEntity entity, IReadOnlyList<TagEntity> taskTags, CancellationToken ct = default)
{
_editId = entity.Id;
_listId = entity.ListId;
_createdAt = entity.CreatedAt;
Title = entity.Title;
Description = entity.Description;
CommitType = entity.CommitType;
StatusChoice = entity.Status switch
{
TaskStatus.Manual => "manual",
TaskStatus.Queued => "queued",
_ => entity.Status.ToString().ToLowerInvariant(),
};
TagsInput = string.Join(", ", taskTags.Select(t => t.Name));
ModelChoice = entity.Model is not null
? ListEditorViewModel.ModelIdToDisplay(entity.Model)
: "(list default)";
SystemPromptOverride = entity.SystemPrompt;
if (entity.AgentPath is not null)
{
var match = AvailableAgents.FirstOrDefault(a => a.Path == entity.AgentPath);
if (match is null)
{
match = new AgentInfo(Path.GetFileNameWithoutExtension(entity.AgentPath), "(external)", entity.AgentPath);
AvailableAgents.Add(match);
OnPropertyChanged(nameof(AvailableAgents));
}
SelectedAgent = match;
}
else
{
SelectedAgent = null;
}
WindowTitle = $"Edit Task: {entity.Title}";
Subtasks.Clear();
var list = await _subtaskRepo.GetByTaskIdAsync(entity.Id, ct);
foreach (var s in list)
Subtasks.Add(SubtaskItemViewModel.From(s));
}
// Keep old sync overload for callers that haven't loaded agents yet
public void InitForEdit(TaskEntity entity, IReadOnlyList<TagEntity> taskTags) public void InitForEdit(TaskEntity entity, IReadOnlyList<TagEntity> taskTags)
{ {
_editId = entity.Id; _editId = entity.Id;
@@ -72,14 +141,34 @@ public partial class TaskEditorViewModel : ViewModelBase
? ListEditorViewModel.ModelIdToDisplay(entity.Model) ? ListEditorViewModel.ModelIdToDisplay(entity.Model)
: "(list default)"; : "(list default)";
SystemPromptOverride = entity.SystemPrompt; SystemPromptOverride = entity.SystemPrompt;
SelectedAgent = entity.AgentPath is not null
? AvailableAgents.FirstOrDefault(a => a.Path == entity.AgentPath) if (entity.AgentPath is not null)
: null; {
var match = AvailableAgents.FirstOrDefault(a => a.Path == entity.AgentPath);
if (match is null)
{
match = new AgentInfo(Path.GetFileNameWithoutExtension(entity.AgentPath), "(external)", entity.AgentPath);
AvailableAgents.Add(match);
OnPropertyChanged(nameof(AvailableAgents));
}
SelectedAgent = match;
}
else
{
SelectedAgent = null;
}
WindowTitle = $"Edit Task: {entity.Title}"; WindowTitle = $"Edit Task: {entity.Title}";
} }
[RelayCommand] [RelayCommand]
private void Save() private void AddSubtask() => Subtasks.Add(new SubtaskItemViewModel());
[RelayCommand]
private void RemoveSubtask(SubtaskItemViewModel item) => Subtasks.Remove(item);
[RelayCommand]
private async Task Save()
{ {
if (string.IsNullOrWhiteSpace(Title)) return; if (string.IsNullOrWhiteSpace(Title)) return;
var status = StatusChoice switch var status = StatusChoice switch
@@ -87,9 +176,10 @@ public partial class TaskEditorViewModel : ViewModelBase
"queued" => TaskStatus.Queued, "queued" => TaskStatus.Queued,
_ => TaskStatus.Manual, _ => TaskStatus.Manual,
}; };
var taskId = _editId ?? Guid.NewGuid().ToString();
var entity = new TaskEntity var entity = new TaskEntity
{ {
Id = _editId ?? Guid.NewGuid().ToString(), Id = taskId,
ListId = _listId, ListId = _listId,
Title = Title.Trim(), Title = Title.Trim(),
Description = string.IsNullOrWhiteSpace(Description) ? null : Description.Trim(), Description = string.IsNullOrWhiteSpace(Description) ? null : Description.Trim(),
@@ -102,6 +192,42 @@ public partial class TaskEditorViewModel : ViewModelBase
: null; : null;
entity.SystemPrompt = string.IsNullOrWhiteSpace(SystemPromptOverride) ? null : SystemPromptOverride.Trim(); entity.SystemPrompt = string.IsNullOrWhiteSpace(SystemPromptOverride) ? null : SystemPromptOverride.Trim();
entity.AgentPath = SelectedAgent?.Path; entity.AgentPath = SelectedAgent?.Path;
// Persist subtask changes
if (_editId is not null)
{
var existing = await _subtaskRepo.GetByTaskIdAsync(taskId);
var existingIds = existing.Select(s => s.Id).ToHashSet();
var currentIds = Subtasks.Where(s => s.Id != "").Select(s => s.Id).ToHashSet();
// Deleted
foreach (var id in existingIds.Except(currentIds))
await _subtaskRepo.DeleteAsync(id);
// Updated
foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)))
{
if (vm.Id == "") continue;
if (vm.Title != vm.OriginalTitle || vm.Completed != vm.OriginalCompleted)
await _subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow });
else
{
// update order_num if position changed
var orig = existing.FirstOrDefault(e => e.Id == vm.Id);
if (orig is not null && orig.OrderNum != idx)
await _subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = orig.CreatedAt });
}
}
}
// Added (id == "" means new)
foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)).Where(x => x.v.Id == ""))
{
if (string.IsNullOrWhiteSpace(vm.Title)) continue;
var newId = Guid.NewGuid().ToString();
await _subtaskRepo.AddAsync(new SubtaskEntity { Id = newId, TaskId = taskId, Title = vm.Title.Trim(), Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow });
}
_tcs.TrySetResult(entity); _tcs.TrySetResult(entity);
RequestClose?.Invoke(); RequestClose?.Invoke();
} }

View File

@@ -194,7 +194,7 @@ public partial class TaskListViewModel : ViewModelBase
var taskTags = await _taskRepo.GetTagsAsync(entity.Id); var taskTags = await _taskRepo.GetTagsAsync(entity.Id);
var editor = _editorFactory(); var editor = _editorFactory();
await editor.LoadAgentsAsync(_worker); await editor.LoadAgentsAsync(_worker);
editor.InitForEdit(entity, taskTags); await editor.InitForEditAsync(entity, taskTags);
var window = new TaskEditorView { DataContext = editor }; var window = new TaskEditorView { DataContext = editor };
editor.RequestClose += () => window.Close(); editor.RequestClose += () => window.Close();

View File

@@ -18,7 +18,7 @@
<!-- Lists island --> <!-- Lists island -->
<Border Grid.Column="0" CornerRadius="12" Background="{StaticResource IslandBgBrush}" <Border Grid.Column="0" CornerRadius="12" Background="{StaticResource IslandBgBrush}"
MinWidth="180" MaxWidth="320" Margin="0,0,4,8" ClipToBounds="True"> MinWidth="180" Margin="0,0,4,8" ClipToBounds="True">
<DockPanel> <DockPanel>
<TextBlock DockPanel.Dock="Top" <TextBlock DockPanel.Dock="Top"
Text="Lists" FontWeight="SemiBold" FontSize="13" Text="Lists" FontWeight="SemiBold" FontSize="13"
@@ -95,7 +95,7 @@
<!-- Detail island --> <!-- Detail island -->
<Border Grid.Column="2" CornerRadius="12" Background="{StaticResource IslandBgBrush}" <Border Grid.Column="2" CornerRadius="12" Background="{StaticResource IslandBgBrush}"
MinWidth="280" MaxWidth="500" Margin="4,0,0,8" ClipToBounds="True"> MinWidth="280" Margin="4,0,0,8" ClipToBounds="True">
<v:TaskDetailView DataContext="{Binding TaskDetail}" /> <v:TaskDetailView DataContext="{Binding TaskDetail}" />
</Border> </Border>
</Grid> </Grid>

View File

@@ -86,6 +86,71 @@
PlaceholderText="Add a description..." PlaceholderText="Add a description..."
LostFocus="OnFieldLostFocus"/> LostFocus="OnFieldLostFocus"/>
<!-- Sub-Tasks -->
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,8,0,4"/>
<TextBlock Text="Sub-Tasks" FontWeight="Bold" FontSize="13"
Foreground="{StaticResource TextPrimaryBrush}"/>
<ItemsControl ItemsSource="{Binding Subtasks}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SubtaskItemViewModel">
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,2,0,2">
<CheckBox IsChecked="{Binding Completed}" VerticalAlignment="Center"/>
<TextBox Text="{Binding Title}" PlaceholderText="Subtask title..." Width="220"
VerticalAlignment="Center"
LostFocus="OnSubtaskTitleLostFocus"/>
<Button Content="✕" Padding="6,2"
Background="Transparent" BorderThickness="0"
Foreground="{StaticResource TextMutedBrush}"
Command="{Binding $parent[UserControl].((vm:TaskDetailViewModel)DataContext).RemoveSubtaskCommand}"
CommandParameter="{Binding}"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Content="+ Add Sub-Task" Command="{Binding AddSubtaskCommand}"
Background="Transparent" BorderThickness="0"
Foreground="{StaticResource AccentLightBrush}" HorizontalAlignment="Left"/>
<!-- Agent Config (overrides) -->
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,8,0,4"/>
<TextBlock Text="Agent Config (overrides)" FontWeight="Bold" FontSize="13"
Foreground="{StaticResource TextPrimaryBrush}"/>
<Grid ColumnDefinitions="*,12,*" Margin="0,4,0,0">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Model" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"/>
<ComboBox ItemsSource="{Binding ModelChoices}"
SelectedItem="{Binding ModelChoice}"
MinWidth="100"
LostFocus="OnFieldLostFocus"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Agent File" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"/>
<StackPanel Orientation="Horizontal" Spacing="4">
<ComboBox ItemsSource="{Binding AvailableAgents}"
SelectedItem="{Binding SelectedAgent}"
MinWidth="100"
LostFocus="OnFieldLostFocus">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="m:AgentInfo">
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Content="…" Click="OnBrowseAgent" ToolTip.Tip="Browse for agent .md file"/>
</StackPanel>
</StackPanel>
</Grid>
<TextBlock Text="System Prompt" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,2"/>
<TextBox Text="{Binding SystemPromptOverride}"
PlaceholderText="(inherits from list)"
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="50"
LostFocus="OnFieldLostFocus"/>
<!-- === READ-ONLY ZONE === --> <!-- === READ-ONLY ZONE === -->
<TextBlock Text="Result" FontWeight="SemiBold" FontSize="12" <TextBlock Text="Result" FontWeight="SemiBold" FontSize="12"

View File

@@ -2,6 +2,7 @@ using System.ComponentModel;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.ViewModels;
namespace ClaudeDo.Ui.Views; namespace ClaudeDo.Ui.Views;
@@ -19,6 +20,31 @@ public partial class TaskDetailView : UserControl
await vm.SaveAsync(); await vm.SaveAsync();
} }
private void OnSubtaskTitleLostFocus(object? sender, RoutedEventArgs e)
{
// Title change is handled by SubtaskItemViewModel.PropertyChanged → OnSubtaskPropertyChanged in the VM
}
private async void OnBrowseAgent(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null) return;
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Select Agent File",
AllowMultiple = false,
FileTypeFilter = new[] { new FilePickerFileType("Agent Files") { Patterns = ["*.md"] } },
});
if (files.Count == 0) return;
var path = files[0].TryGetLocalPath();
if (path is null) return;
if (DataContext is TaskDetailViewModel vm)
{
vm.SetAgentFromPath(path);
await vm.SaveAsync();
}
}
private void OnTagInputKeyDown(object? sender, KeyEventArgs e) private void OnTagInputKeyDown(object? sender, KeyEventArgs e)
{ {
if (e.Key == Key.Enter && DataContext is TaskDetailViewModel vm) if (e.Key == Key.Enter && DataContext is TaskDetailViewModel vm)

View File

@@ -35,6 +35,30 @@
<TextBlock Text="Tags (comma-separated)" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/> <TextBlock Text="Tags (comma-separated)" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
<TextBox Text="{Binding TagsInput}" PlaceholderText="agent, manual, code, ..."/> <TextBox Text="{Binding TagsInput}" PlaceholderText="agent, manual, code, ..."/>
<!-- Sub-Tasks -->
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,6,0,2"/>
<TextBlock Text="Sub-Tasks" FontWeight="Bold" FontSize="13"
Foreground="{StaticResource TextPrimaryBrush}"/>
<ItemsControl ItemsSource="{Binding Subtasks}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SubtaskItemViewModel">
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,2,0,2">
<CheckBox IsChecked="{Binding Completed}" VerticalAlignment="Center"/>
<TextBox Text="{Binding Title}" PlaceholderText="Subtask title..." Width="320"
VerticalAlignment="Center"/>
<Button Content="✕" Padding="6,2"
Background="Transparent" BorderThickness="0"
Foreground="{StaticResource TextMutedBrush}"
Command="{Binding $parent[Window].((vm:TaskEditorViewModel)DataContext).RemoveSubtaskCommand}"
CommandParameter="{Binding}"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Content="+ Add Sub-Task" Command="{Binding AddSubtaskCommand}"
Background="Transparent" BorderThickness="0"
Foreground="{StaticResource AccentLightBrush}" HorizontalAlignment="Left"/>
<!-- Divider --> <!-- Divider -->
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,6,0,2"/> <Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,6,0,2"/>
@@ -55,15 +79,18 @@
<TextBlock Text="Agent File" FontWeight="SemiBold" <TextBlock Text="Agent File" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"/> Foreground="{StaticResource TextSecondaryBrush}"/>
<ComboBox ItemsSource="{Binding AvailableAgents}" <StackPanel Orientation="Horizontal" Spacing="6">
SelectedItem="{Binding SelectedAgent}" <ComboBox ItemsSource="{Binding AvailableAgents}"
MinWidth="150"> SelectedItem="{Binding SelectedAgent}"
<ComboBox.ItemTemplate> MinWidth="150">
<DataTemplate x:DataType="models:AgentInfo"> <ComboBox.ItemTemplate>
<TextBlock Text="{Binding Name}"/> <DataTemplate x:DataType="models:AgentInfo">
</DataTemplate> <TextBlock Text="{Binding Name}"/>
</ComboBox.ItemTemplate> </DataTemplate>
</ComboBox> </ComboBox.ItemTemplate>
</ComboBox>
<Button Content="…" Click="OnBrowseAgent" ToolTip.Tip="Browse for agent .md file"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" Margin="0,10,0,0"> <StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" Margin="0,10,0,0">
<Button Content="Save" Command="{Binding SaveCommand}" IsDefault="True" MinWidth="80" <Button Content="Save" Command="{Binding SaveCommand}" IsDefault="True" MinWidth="80"

View File

@@ -1,4 +1,7 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClaudeDo.Ui.ViewModels;
namespace ClaudeDo.Ui.Views; namespace ClaudeDo.Ui.Views;
@@ -8,4 +11,19 @@ public partial class TaskEditorView : Window
{ {
InitializeComponent(); InitializeComponent();
} }
private async void OnBrowseAgent(object? sender, RoutedEventArgs e)
{
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Select Agent File",
AllowMultiple = false,
FileTypeFilter = new[] { new FilePickerFileType("Agent Files") { Patterns = ["*.md"] } },
});
if (files.Count == 0) return;
var path = files[0].TryGetLocalPath();
if (path is null) return;
if (DataContext is TaskEditorViewModel vm)
vm.SetAgentFromPath(path);
}
} }

View File

@@ -19,6 +19,7 @@ builder.Services.AddSingleton(dbFactory);
builder.Services.AddSingleton<TagRepository>(); builder.Services.AddSingleton<TagRepository>();
builder.Services.AddSingleton<ListRepository>(); builder.Services.AddSingleton<ListRepository>();
builder.Services.AddSingleton<TaskRepository>(); builder.Services.AddSingleton<TaskRepository>();
builder.Services.AddSingleton<SubtaskRepository>();
builder.Services.AddSingleton<WorktreeRepository>(); builder.Services.AddSingleton<WorktreeRepository>();
builder.Services.AddSingleton<TaskRunRepository>(); builder.Services.AddSingleton<TaskRunRepository>();
builder.Services.AddHostedService<StaleTaskRecovery>(); builder.Services.AddHostedService<StaleTaskRecovery>();

View File

@@ -12,6 +12,7 @@ public sealed class TaskRunner
private readonly TaskRunRepository _runRepo; private readonly TaskRunRepository _runRepo;
private readonly ListRepository _listRepo; private readonly ListRepository _listRepo;
private readonly WorktreeRepository _wtRepo; private readonly WorktreeRepository _wtRepo;
private readonly SubtaskRepository _subtaskRepo;
private readonly HubBroadcaster _broadcaster; private readonly HubBroadcaster _broadcaster;
private readonly WorktreeManager _wtManager; private readonly WorktreeManager _wtManager;
private readonly ClaudeArgsBuilder _argsBuilder; private readonly ClaudeArgsBuilder _argsBuilder;
@@ -24,6 +25,7 @@ public sealed class TaskRunner
TaskRunRepository runRepo, TaskRunRepository runRepo,
ListRepository listRepo, ListRepository listRepo,
WorktreeRepository wtRepo, WorktreeRepository wtRepo,
SubtaskRepository subtaskRepo,
HubBroadcaster broadcaster, HubBroadcaster broadcaster,
WorktreeManager wtManager, WorktreeManager wtManager,
ClaudeArgsBuilder argsBuilder, ClaudeArgsBuilder argsBuilder,
@@ -35,6 +37,7 @@ public sealed class TaskRunner
_runRepo = runRepo; _runRepo = runRepo;
_listRepo = listRepo; _listRepo = listRepo;
_wtRepo = wtRepo; _wtRepo = wtRepo;
_subtaskRepo = subtaskRepo;
_broadcaster = broadcaster; _broadcaster = broadcaster;
_wtManager = wtManager; _wtManager = wtManager;
_argsBuilder = argsBuilder; _argsBuilder = argsBuilder;
@@ -91,9 +94,16 @@ public sealed class TaskRunner
await _broadcaster.TaskStarted(slot, task.Id, now); await _broadcaster.TaskStarted(slot, task.Id, now);
// Build prompt. // Build prompt.
var prompt = string.IsNullOrWhiteSpace(task.Description) var subtasks = await _subtaskRepo.GetByTaskIdAsync(task.Id, ct);
? task.Title var sb = new System.Text.StringBuilder(task.Title);
: $"{task.Title}\n\n{task.Description.Trim()}"; if (!string.IsNullOrWhiteSpace(task.Description)) sb.Append("\n\n").Append(task.Description.Trim());
if (subtasks.Count > 0)
{
sb.Append("\n\n## Sub-Tasks\n");
foreach (var s in subtasks)
sb.Append(s.Completed ? "- [x] " : "- [ ] ").Append(s.Title).Append('\n');
}
var prompt = sb.ToString();
// Run 1. // Run 1.
var result = await RunOnceAsync(task.Id, slot, runDir, resolvedConfig, 1, false, prompt, ct); var result = await RunOnceAsync(task.Id, slot, runDir, resolvedConfig, 1, false, prompt, ct);

View File

@@ -51,7 +51,8 @@ public sealed class QueueServiceTests : IDisposable
var runRepo = new TaskRunRepository(_db.Factory); var runRepo = new TaskRunRepository(_db.Factory);
var wtManager = new WorktreeManager(new GitService(), wtRepo, _cfg, NullLogger<WorktreeManager>.Instance); var wtManager = new WorktreeManager(new GitService(), wtRepo, _cfg, NullLogger<WorktreeManager>.Instance);
var argsBuilder = new ClaudeArgsBuilder(); var argsBuilder = new ClaudeArgsBuilder();
var runner = new TaskRunner(fake, _taskRepo, runRepo, _listRepo, wtRepo, broadcaster, wtManager, argsBuilder, _cfg, var subtaskRepo = new SubtaskRepository(_db.Factory);
var runner = new TaskRunner(fake, _taskRepo, runRepo, _listRepo, wtRepo, subtaskRepo, broadcaster, wtManager, argsBuilder, _cfg,
NullLogger<TaskRunner>.Instance); NullLogger<TaskRunner>.Instance);
var service = new QueueService(_taskRepo, runner, _cfg, NullLogger<QueueService>.Instance); var service = new QueueService(_taskRepo, runner, _cfg, NullLogger<QueueService>.Instance);
return (service, fake); return (service, fake);