Compare commits

..
5 Commits
Author SHA1 Message Date
mika kuns c31f79d3d4 feat(ui): wählbares Listen-Icon und aufgeräumter Task-Detail-Header
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 41s
Listen bekommen eine Icon-Spalte (Migration AddListIcon) und einen Icon-Picker
in den Listeneinstellungen; 34 kuratierte Geometrien aus der Icon-Bibliothek,
Fallback auf "Folder" bei unbekanntem Key.

Der Metadata-Footer im Task-Detail entfällt; created-at wandert in die
Header-Zeile neben das ID-Badge, der Close-Button (und CloseDetailsCommand)
fällt weg.
2026-08-28 14:17:38 +02:00
mika kuns 22e70ffcb4 feat(ui): Mission Control als Session-Hub
- "+" öffnet ein Menü mit allen Listen (working dir) statt direkt den
  Ordner-Dialog; "Ordner wählen…" bleibt als Fallback erste Zeile, damit
  das Menü auch bei fehlgeschlagener Listen-Abfrage nutzbar bleibt
- Rail-Rows: Kontextmenü "Sitzung öffnen" (gesperrt für Running — der
  Worktree gehört dort dem autonomen Lauf)
- Aktivitäts-Strip statt reiner Queue: WaitingForReview/Failed/Roadblock
  kommen dazu, Attention zuerst, unterschieden nur über Farbe (Aktionen
  bleiben in der Task-Detailansicht)
2026-08-28 14:17:38 +02:00
Mika Kuns 8d597df448 fix(worker): make re-approve after revert_merge do a real merge, not a silent no-op
ApproveAndMergeAsync treated any non-Active worktree (including Kept, the
state RevertMergeAsync leaves behind) the same as "never had a worktree",
so re-approving a reverted task ran only the verify gate and marked the
task Done without merging anything back.

Now a Kept worktree is distinguished: if its branch is gone (the common
case, since approve normally deletes it), approve returns Blocked instead
of a fake "merged". If the branch survives, its tip is already an ancestor
of the target (from the original merge), so a plain `git merge` would
silently no-op ("Already up to date."); cherry-picking the original merge
commit's own diff (-m 1) is what actually restores the content, recorded
as a fresh MergeCommit.
2026-08-28 14:17:38 +02:00
Mika Kuns 1affb39716 fix(worker): survive a worker restart when cancelling a mid-merge unit-merge parent
CancelAsync only checked PlanningMergeOrchestrator's in-memory HasActiveMerge, which is
empty after a restart. IActiveMergeState gains an async fallback that checks on-disk
(GitService.IsMidMergeAsync) for a WaitingForReview parent with children, so the guard
still blocks cancel until the merge is continued or aborted.
2026-08-28 14:17:38 +02:00
ClaudeDo CI bbdb022327 docs(changelog): update for v2.11.0 2026-08-27 14:47:02 +00:00
32 changed files with 1829 additions and 89 deletions
+120
View File
@@ -1,5 +1,125 @@
# Changelog
## v2.11.0 — 2026-08-27
### Features
- Listeneinstellungen mit Kategorie-Sidebar (4e55f916)
- Ticketsystem-Anbindung hinter einen Aus-Schalter legen (8329c925)
- Ticket-Projekt pro Liste und Import ueber das Kontextmenue (e91ade9c)
- Settings-Tab fuer die Ticketsystem-Anbindung (020f93d5)
- Ticket-Settings, Projektliste und Import ueber den Hub (f0a3a186)
- Ticket-Import pro Liste (2e236736)
- Ticket-Status-Rueckmeldung ueber TaskStateService (5aafeb6c)
- BandelTicketClient fuer die Ticketsystem-REST-API (10652780)
- TicketSystemConfig + ticket_api_base_url in worker.config.json (2a3c937f)
- ticket_ref auf tasks, ticket_project_id auf list_config (3171690b)
- claude_bin im Dateien-Tab setzbar, mit Auflösungs-Check (13b81cac)
- Throttle-Stufen im Ausfuehrung-Tab sichtbar machen (31d53b00)
- Permission-Modus pro Liste und pro Task ueberschreibbar (4464dc6f)
- Listen-Einstellungen — Checkbox fuer serialize_on_file_overlap (5cc99bfe)
- Review-Merge-Button — Claude nimmt Waiting-for-Review pro Liste ab (741c7ac5)
- verify once per batch via skipVerify + verify_merges (168ab1cd)
- run both MCP servers stateless (69fcceb2)
- add the Prime action radio group to settings (f3cb34b9)
- add the action kind to the settings viewmodels (57be4961)
- add Prime action-kind strings in en and de (514f142a)
- carry the action kind through the hub and validate custom prompts (4e8e0334)
- branch PrimeRunner on the schedule action kind (cf6a6b62)
- migrate prime_schedules with action_kind defaulting to ping (71d2972a)
- add PrimeActionKind to the schedule entity (022eaf38)
- open the TokenTracker dashboard from the usage monitor (3003cfa5)
- show cost columns and the TokenTracker hint card in the usage monitor (074c0e4d)
- surface TokenTracker status and cost in the usage monitor (bac0d69a)
- add TokenTracker analytics strings in en and de (66f743dd)
- add TokenTracker status, refresh and install to the worker client (3c3bb306)
- serve model and task usage from the TokenTracker export (44337d4b)
- cache the TokenTracker export with single-flight refresh (3f3638c1)
- run the TokenTracker CLI through the shared executable resolver (a9218045)
- pin the TokenTracker CLI invocations (d6b22ea5)
- hold the last good TokenTracker export fail-open (580d4e07)
- aggregate TokenTracker sessions by model and per task (2fca0cc4)
- parse the TokenTracker sessions export defensively (106f9a37)
- add TokenTracker export models and test fixture (16952b64)
- reproduce TokenTracker session_hash for task attribution (1cb574bd)
- Improve App Prompts (79438c00)
- Toggle "Continue on session limit reset" für Usage-Limit-Abbrüche (07dd7570)
- confirm main-window close when Mission Control sessions are open (0892883a)
- "Worktree öffnen" im Kontextmenü der Task-Zeile (ac9b0dab)
- update_task kann isManual setzen + List-Handler stellt seinen Task auf Review (ea2271cc)
- Usage-Monitor zeigt Reset-Uhrzeit hinter der Restzeit (8f983851)
- Erststart-Banner und Textkette Repo→Task→Queue→Review (UX-Struktur C) (111dd0fb)
- Settings-Modal auf Sidebar-Kategorien umbauen (56c3719e)
- Task-Zeile entdichten und Kontextmenü gruppieren (UX-Struktur A) (51dc7bd6)
- surface startup recovery via OperationProgress channel (6ec81f65)
- report worktree-creation phase on OperationProgress (eba0d842)
- surface rebase and worktree-maintenance progress on OperationProgress (42de97e1)
- Empty States für Detail-Pane und Task-Liste (UX-Audit #4) (9159f1b5)
### Fixes
- Base-URL ohne Schema laesst die Ticket-Anbindung scheitern (3322152b)
- Import-Erfolgsmeldung nicht mehr als Fehler einfaerben (2fe2ac83)
- MergeHelperSelectionModal — Task-Nummer anzeigen, Titel wrappen, Status-Spalte verbreitern (24e1d648)
- Branch-Zeile im Detail-Header — Icon stroked rendern, ohne Task ausblenden (18755910)
- Review-Merge-Button-Icon stroked rendern statt gefuellt — Linien waren unsichtbar (d7a3aa80)
- block delete on interactive-session tasks, clear ghost selection (c1ab042d)
- store ScheduledFor as real UTC instead of mislabeled local time (f70aec49)
- make modify/delete and rename/delete merge conflicts visible instead of silently staging them (533a287e)
- close status-force bypasses on Running tasks (299c7b74)
- allow the done toggle from any non-Running status (f80af122)
- restore the Settings modal body and tidy the Prime schedule row (d71c0cea)
- close the typographic quote that made de.json unparseable (9ab17d82)
- failureReason "usage_limit" nachziehen + Worktree-Gate prüft die Platte (29171b10)
- Usage-Limit-Auto-Continue verbrennt nicht mehr alle Kandidaten außer dem ersten (3dfae30f)
- thread handler task id into list-handler kickoff prompts (9c4a63d2)
- UsagePillViewModel loads snapshot on connection restore (7f94bf4a)
- Description-Editbox nutzt volle Kartenhoehe statt 320px-Deckel (a6d165a1)
- route done-toggle and dequeue through guarded TaskStateService transitions (7f337b36)
- Reactivität — Description-Save-Race, UsageMonitor-Leak, Listen-Live-Refresh (52efe3ec)
- notify IsTasksEmptyRepoHintVisible on every list switch (b997af44)
- Aktions- und Bestätigungs-Konsistenz (UX-Audit #3) (2935eef7)
- surface precondition hints for silently-disabled review buttons (UX-Audit #2) (dbb73bae)
- silent no-ops raise ErrorReported instead of swallowing failures (UX-Audit #1) (99bb5be5)
- propagate unit-merge failures instead of reporting success (f2058430)
- Stuck-Running-Fenster in Continue- und Stop-Pfad schließen (389c9126)
### Refactoring
- Datei-Scope-Serialisierung entfernen (84219a9f)
- OnlineTokenStore zu DpapiTokenStore verallgemeinert (41bfc242)
- Icon-Faerbung zentral im ICON-PAINTING-Block (1ef67760)
- drop the remaining single-implementation interfaces (0f007c53)
- merge the one-expression converters into SimpleConverters.cs (c593be2f)
- declare the SignalR wire DTOs once in ClaudeDo.Data (8930b564)
- squash the 40 migrations into one InitialCreate (39d82413)
- collapse single-implementation interfaces (296251e2)
- delete unreachable members and redundant package refs (81994aad)
- drop the transcript aggregation path in favour of TokenTracker (40879705)
- dedupe path-open helper across five view models (b2a1b790)
- derive TaskRowViewModel CanX/DisabledReason pairs from one gate method (baba9216)
- unify list-linked-repo predicate into RepoLinkage (9578c950)
- dedupe FormatRemaining between UsagePill and UsageMonitorModal (e164f1e3)
### Documentation
- Ticketsystem-Anbindung als pausiert vermerken (cb69e2a7)
- Ticketsystem-Anbindung in den CLAUDE.md-Dateien (fb1ceac1)
- IsConfigured-Kosten praezise beschreiben (e9dfea90)
- Implementierungsplan, Base-URL in worker.config.json (5a13ad44)
- Design fuer die Ticketsystem-Anbindung (63184ea3)
- neue Config-Felder + Migration-Fixture-Falle in den CLAUDE.md-Dateien (d474fd13)
- bump review-merge verified-against to 168ab1cd (187f5fc5)
- document the schedule action kinds (a1d74ab4)
- implementation plan for schedule action kinds (2dec590d)
- add the TokenTracker analytics implementation plan (884b990e)
- document TokenTracker as the analytics backend (c0bf7db6)
- spec per-schedule action kind (Ping/FillMyDay/Custom) (a873eaa9)
- spec for TokenTracker as the token-analytics backend (0e74650d)
- C5 periodische Dienste abgesagt — kein Mehrwert über bestehende Broadcasts (29868962)
- design and plan for the four structural UX themes (4cf08f81)
- update for v2.10.0 (925ba655)
## v2.10.0 — 2026-08-17
### Features
@@ -19,6 +19,7 @@ public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
builder.Property(l => l.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
builder.Property(l => l.IsManual).HasColumnName("is_manual").IsRequired().HasDefaultValue(false);
builder.Property(l => l.FindingsTracked).HasColumnName("findings_tracked").IsRequired().HasDefaultValue(false);
builder.Property(l => l.Icon).HasColumnName("icon");
builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort");
+32
View File
@@ -356,6 +356,38 @@ public sealed class GitService
throw new InvalidOperationException($"git revert --abort failed (exit {exitCode}): {stderr}");
}
/// <summary>
/// Re-applies a merge commit's own diff (against its first parent, `-m 1`) as a new commit on
/// top of HEAD. Needed to redo a merge that was previously reverted: once a branch tip is an
/// ancestor of HEAD, `git merge` on that branch again is a silent "Already up to date." no-op
/// (ancestry, not content, is what merge checks) -- cherry-picking the original merge commit's
/// diff is what actually restores the content.
/// </summary>
public async Task<(int ExitCode, string Stderr)> CherryPickMergeCommitAsync(
string repoDir, string mergeCommitSha, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
["-c", "merge.conflictStyle=diff3", "cherry-pick", "-m", "1", "--no-edit", mergeCommitSha], ct);
return (exitCode, stderr);
}
public async Task<bool> IsMidCherryPickAsync(string repoDir, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["rev-parse", "--git-dir"], ct);
if (exitCode != 0) return false;
var gitDir = stdout.Trim();
if (!Path.IsPathRooted(gitDir))
gitDir = Path.Combine(repoDir, gitDir);
return File.Exists(Path.Combine(gitDir, "CHERRY_PICK_HEAD"));
}
public async Task CherryPickAbortAsync(string repoDir, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["cherry-pick", "--abort"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git cherry-pick --abort failed (exit {exitCode}): {stderr}");
}
public async Task<List<string>> ListConflictedFilesAsync(string repoDir, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
@@ -0,0 +1,978 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260828084750_AddListIcon")]
partial class AddListIcon
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<bool>("AutoContinueOnUsageLimit")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("auto_continue_on_usage_limit");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(40)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<int>("MaxTurnsCeiling")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("max_turns_ceiling");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<int>("NextTaskNumber")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("next_task_number");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("UsageThrottleFiveHourHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_five_hour_hard_pct");
b.Property<int>("UsageThrottleFiveHourSoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_five_hour_soft_pct");
b.Property<int>("UsageThrottleSevenDayHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_seven_day_hard_pct");
b.Property<int>("UsageThrottleSevenDaySoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_seven_day_soft_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
AutoContinueOnUsageLimit = false,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 40,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
MaxTurnsCeiling = 80,
NextTaskNumber = 1,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
UsageThrottleFiveHourHardPct = 65,
UsageThrottleFiveHourSoftPct = 50,
UsageThrottleSevenDayHardPct = 65,
UsageThrottleSevenDaySoftPct = 50,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("PermissionMode")
.HasColumnType("TEXT")
.HasColumnName("permission_mode");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<int?>("TicketProjectId")
.HasColumnType("INTEGER")
.HasColumnName("ticket_project_id");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("FindingsTracked")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("findings_tracked");
b.Property<string>("Icon")
.HasColumnType("TEXT")
.HasColumnName("icon");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<string>("Kind")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("ping")
.HasColumnName("action_kind");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("DependsOnTaskId")
.HasColumnType("TEXT")
.HasColumnName("depends_on_task_id");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<int?>("FailureMaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("failure_max_turns");
b.Property<string>("FailureReason")
.HasColumnType("TEXT")
.HasColumnName("failure_reason");
b.Property<int?>("FailureTurnsUsed")
.HasColumnType("INTEGER")
.HasColumnName("failure_turns_used");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<string>("InteractiveSessionId")
.HasColumnType("TEXT")
.HasColumnName("interactive_session_id");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<int>("Number")
.HasColumnType("INTEGER")
.HasColumnName("number");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<string>("PermissionMode")
.HasColumnType("TEXT")
.HasColumnName("permission_mode");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("TicketRef")
.HasColumnType("TEXT")
.HasColumnName("ticket_ref");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.Property<DateTime?>("UsageLimitAutoContinuedAt")
.HasColumnType("TEXT")
.HasColumnName("usage_limit_auto_continued_at");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("DependsOnTaskId")
.HasDatabaseName("idx_tasks_depends_on");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("Number")
.IsUnique()
.HasDatabaseName("idx_tasks_number");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<int?>("CacheReadTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_read_tokens");
b.Property<int?>("CacheWriteTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_write_tokens");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<string>("Errors")
.HasColumnType("TEXT")
.HasColumnName("errors");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<string>("ResultSubtype")
.HasColumnType("TEXT")
.HasColumnName("result_subtype");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("TerminalReason")
.HasColumnType("TEXT")
.HasColumnName("terminal_reason");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("DependsOnTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddListIcon : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "icon",
table: "lists",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "icon",
table: "lists");
}
}
}
@@ -287,6 +287,10 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue(false)
.HasColumnName("findings_tracked");
b.Property<string>("Icon")
.HasColumnType("TEXT")
.HasColumnName("icon");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
+2
View File
@@ -14,6 +14,8 @@ public sealed class ListEntity
// When true the project's .claudedo/ findings store is committed with the repo; when false it
// is kept out of git via .git/info/exclude (see FindingsStore).
public bool FindingsTracked { get; set; }
// Icon key into the UI's icon library (e.g. "Star"); null = the default folder icon.
public string? Icon { get; set; }
// Navigation properties
public ListConfigEntity? Config { get; set; }
+1 -1
View File
@@ -92,7 +92,7 @@ public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<Merg
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false, bool FindingsTracked = false);
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false, bool FindingsTracked = false, string? Icon = null);
// TicketProjectId is tri-state on purpose: a caller that doesn't own the field (anything but the
// list-settings modal) must not be able to clear it by omission — SetConfigAsync copies the entity
+9 -2
View File
@@ -261,7 +261,6 @@
"killSessionTip": "Laufende Sitzung beenden",
"sendToQueue": "In Warteschlange einreihen",
"sendToQueueTip": "Diese Aufgabe in die Warteschlange einreihen, damit Claude sie beim nächsten Lauf übernimmt",
"closeTip": "Schließen",
"copyTaskIdTip": "Aufgaben-ID kopieren",
"starTip": "Favorit",
"agentSettingsTip": "Agent-Einstellungen",
@@ -320,9 +319,14 @@
"newSession": "Neue Sitzung",
"empty": "Keine laufenden Aufgaben",
"settings": "Einstellungen",
"queue": "Warteschlange",
"activity": "Aktivität",
"blocked": "Blockiert",
"running": "Läuft",
"review": "Im Review",
"failed": "Fehlgeschlagen",
"roadblock": "Blockade gemeldet",
"openSession": "Sitzung öffnen",
"browseForFolder": "Ordner wählen…",
"focusMode": "Fokus",
"overviewMode": "Übersicht",
"closeSession": "Sitzung beenden",
@@ -391,6 +395,9 @@
"manualListHint": "Neue Aufgaben in dieser Liste sind zunächst manuell: kein Einreihen, Ausführen oder Verfeinern, und die Automatik überspringt sie. Eine handgesteuerte Sitzung kannst du weiterhin öffnen.",
"findingsTracked": "Findings-Ordner .claudedo einchecken",
"findingsTrackedHint": "Aus: der Ordner bleibt über .git/info/exclude aus git heraus. An: Findings reisen mit dem Repo.",
"sectionIcon": "ICON",
"icon": "Listen-Icon",
"iconHint": "Wird in der Seitenleiste neben dem Listennamen angezeigt.",
"sectionAgent": "AGENT",
"resetAgentSettings": "Agent-Einstellungen zurücksetzen",
"sectionVerify": "VERIFIKATION",
+9 -2
View File
@@ -261,7 +261,6 @@
"killSessionTip": "Kill the running session",
"sendToQueue": "Send to queue",
"sendToQueueTip": "Queue this task so Claude picks it up on the next run",
"closeTip": "Close",
"copyTaskIdTip": "Copy task ID",
"starTip": "Star",
"agentSettingsTip": "Agent settings",
@@ -320,9 +319,14 @@
"newSession": "New session",
"empty": "No running tasks",
"settings": "Settings",
"queue": "Queue",
"activity": "Activity",
"blocked": "Blocked",
"running": "Running",
"review": "In review",
"failed": "Failed",
"roadblock": "Roadblock reported",
"openSession": "Open session",
"browseForFolder": "Choose folder…",
"focusMode": "Focus",
"overviewMode": "Overview",
"closeSession": "Kill session",
@@ -391,6 +395,9 @@
"manualListHint": "New tasks in this list start out manual: no queueing, running or refining, and automation skips them. You can still open a hand-driven session.",
"findingsTracked": "Commit the .claudedo findings folder",
"findingsTrackedHint": "Off: the folder stays out of git via .git/info/exclude. On: findings travel with the repo.",
"sectionIcon": "ICON",
"icon": "List icon",
"iconHint": "Shown next to the list name in the sidebar.",
"sectionAgent": "AGENT",
"resetAgentSettings": "Reset agent settings",
"sectionVerify": "VERIFICATION",
+88 -10
View File
@@ -33,8 +33,9 @@
<!-- All d-strings sourced from icons.jsx -->
<!-- ============================================================ -->
<!-- Icon.Sun -->
<StreamGeometry x:Key="Icon.Sun">M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M12 3v2M12 19v2M3 12h2M19 12h2M5.5 5.5l1.4 1.4M17.1 17.1l1.4 1.4M5.5 18.5l1.4-1.4M17.1 6.9l1.4-1.4</StreamGeometry>
<!-- Icon.Sun — filled disc + 8 rays. The old stroke-only version rendered as a bare dot in a
PathIcon (verified by rendering it); rays are kept clear of the disc so EvenOdd is safe. -->
<StreamGeometry x:Key="Icon.Sun">M7.5 12 C7.5 9.51 9.51 7.5 12 7.5 C14.49 7.5 16.5 9.51 16.5 12 C16.5 14.49 14.49 16.5 12 16.5 C9.51 16.5 7.5 14.49 7.5 12 Z M11 2 H13 V5.5 H11 Z M11 18.5 H13 V22 H11 Z M2 11 H5.5 V13 H2 Z M18.5 11 H22 V13 H18.5 Z M4.22 5.64 L5.64 4.22 L8.11 6.69 L6.69 8.11 Z M18.36 4.22 L19.78 5.64 L17.31 8.11 L15.89 6.69 Z M5.64 19.78 L4.22 18.36 L6.69 15.89 L8.11 17.31 Z M19.78 18.36 L18.36 19.78 L15.89 17.31 L17.31 15.89 Z</StreamGeometry>
<!-- Icon.Activity (pulse waveform) — filled outline of the polyline; PathIcon fills, a stroke-only pulse renders as blobs -->
<StreamGeometry x:Key="Icon.Activity">M3,11 L6.28,11 L9,2.84 L12.86,14.43 L14.46,8.04 L17.41,11 L21,11 L21,13 L16.59,13 L15.54,11.96 L13.14,21.57 L9,9.16 L7.72,13 L3,13 Z</StreamGeometry>
@@ -42,20 +43,23 @@
<!-- Icon.Star -->
<StreamGeometry x:Key="Icon.Star">M12 3.5l2.6 5.3 5.8.8-4.2 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8-4.2-4.1 5.8-.8z</StreamGeometry>
<!-- Icon.Calendar -->
<StreamGeometry x:Key="Icon.Calendar">M3.5 5a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2v15a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2z M3.5 10h17M8 3v4M16 3v4</StreamGeometry>
<!-- Icon.Calendar — solid header band, hollow body, two binder posts and a date block.
The old stroke version filled into a plain black square. -->
<StreamGeometry x:Key="Icon.Calendar">F0 M3 5 H21 V21 H3 Z M5 11 H19 V19 H5 Z M7 2 H9 V5 H7 Z M15 2 H17 V5 H15 Z M7.5 13 H10 V15.5 H7.5 Z</StreamGeometry>
<!-- Icon.Eye -->
<StreamGeometry x:Key="Icon.Eye">M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z</StreamGeometry>
<!-- Icon.Inbox -->
<StreamGeometry x:Key="Icon.Inbox">M3 13h5l1 2h6l1-2h5M3 13l3-8h12l3 8v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z</StreamGeometry>
<!-- Icon.Inbox — tray frame with an arrow dropping into the upper compartment.
The old stroke version filled into a solid slab. -->
<StreamGeometry x:Key="Icon.Inbox">F0 M3 5 H21 V19 H3 Z M5 7 H19 V12 H5 Z M5 14 H19 V17 H5 Z M11 7.6 H13 V9.4 H15 L12 12 L9 9.4 H11 Z</StreamGeometry>
<!-- Icon.Folder -->
<StreamGeometry x:Key="Icon.Folder">M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z</StreamGeometry>
<!-- Icon.Search -->
<StreamGeometry x:Key="Icon.Search">M11 4a7 7 0 1 0 0 14A7 7 0 0 0 11 4z M20 20l-3.5-3.5</StreamGeometry>
<!-- Icon.Search — hollow ring + handle. The old stroke version filled into a solid disc with
no handle at all (this one is also the magnifier in the lists search box). -->
<StreamGeometry x:Key="Icon.Search">F0 M3 10.5 C3 6.36 6.36 3 10.5 3 C14.64 3 18 6.36 18 10.5 C18 14.64 14.64 18 10.5 18 C6.36 18 3 14.64 3 10.5 Z M5.2 10.5 C5.2 7.57 7.57 5.2 10.5 5.2 C13.43 5.2 15.8 7.57 15.8 10.5 C15.8 13.43 13.43 15.8 10.5 15.8 C7.57 15.8 5.2 13.43 5.2 10.5 Z M14.88 16.58 L16.58 14.88 L22.05 20.35 L20.35 22.05 Z</StreamGeometry>
<!-- Icon.Plus — filled cross (PathIcon fills, so a stroke-only plus renders invisible) -->
<StreamGeometry x:Key="Icon.Plus">M10.6 5 H13.4 V10.6 H19 V13.4 H13.4 V19 H10.6 V13.4 H5 V10.6 H10.6 Z</StreamGeometry>
@@ -87,8 +91,8 @@
<!-- Icon.Stop — filled square (stop / interrupt) -->
<StreamGeometry x:Key="Icon.Stop">M4 4 H20 V20 H4 Z</StreamGeometry>
<!-- Icon.Check -->
<StreamGeometry x:Key="Icon.Check">M4 12l5 5 11-11</StreamGeometry>
<!-- Icon.Check — filled tick. The old stroke version filled into a solid wedge. -->
<StreamGeometry x:Key="Icon.Check">M9.6 18.2 L3.4 12 L5.5 9.9 L9.6 14 L18.5 5.1 L20.6 7.2 Z</StreamGeometry>
<!-- Icon.ArrowOut — filled arrow for "open external" button -->
<StreamGeometry x:Key="Icon.ArrowOut">M13 4 H20 V11 H18 V7.4 L11.4 14 L10 12.6 L16.6 6 H13 Z M4 6 H10 V8 H6 V18 H16 V14 H18 V20 H4 Z</StreamGeometry>
@@ -115,6 +119,75 @@
<!-- Icon.Skull — filled silhouette: rounded cranium + eye holes (EvenOdd) + jaw -->
<StreamGeometry x:Key="Icon.Skull">F0 M12 2 C7 2 4 5.5 4 10 C4 13.5 6 16 8 17.5 L8 19 C8 20 8.9 21 10 21 L10 18.5 L14 18.5 L14 21 C15.1 21 16 20 16 19 L16 17.5 C18 16 20 13.5 20 10 C20 5.5 17 2 12 2 Z M8.5 8 L8.5 12 L11 12 L11 8 Z M13 8 L13 12 L15.5 12 L15.5 8 Z</StreamGeometry>
<!-- ============================================================ -->
<!-- List icons — pickable in list settings → Icon tab. -->
<!-- All filled outlines (PathIcon fills; a stroke-only path is -->
<!-- invisible). Holes rely on the EvenOdd default; the two that -->
<!-- want overlapping subpaths to merge declare F1 explicitly. -->
<!-- ============================================================ -->
<!-- Icon.Box — carton: solid lid band, open body, seam -->
<StreamGeometry x:Key="Icon.Box">F0 M3 5 H21 V20 H3 Z M5 11 H19 V18 H5 Z M11 11 H13 V14 H11 Z</StreamGeometry>
<!-- Icon.Truck — box body + cab + two wheels (F1: wheels overlap the body) -->
<StreamGeometry x:Key="Icon.Truck">F1 M2 6 H13.5 V16 H2 Z M13.5 9.5 H17.4 L21 13.1 V16 H13.5 Z M7 15.5 a2.5 2.5 0 1 0 0 5 a2.5 2.5 0 1 0 0-5 Z M17 15.5 a2.5 2.5 0 1 0 0 5 a2.5 2.5 0 1 0 0-5 Z</StreamGeometry>
<!-- Icon.MapPin — location pin with punched-out centre -->
<StreamGeometry x:Key="Icon.MapPin">F0 M12 2 C8.1 2 5 5.1 5 9 C5 14.2 12 22 12 22 C12 22 19 14.2 19 9 C19 5.1 15.9 2 12 2 Z M12 6.5 a2.5 2.5 0 1 0 0 5 a2.5 2.5 0 1 0 0-5 Z</StreamGeometry>
<!-- Icon.Chart — three rising bars -->
<StreamGeometry x:Key="Icon.Chart">M4 13 H8 V20 H4 Z M10 8 H14 V20 H10 Z M16 4 H20 V20 H16 Z</StreamGeometry>
<!-- Icon.Server — two rack units with punched-out status LEDs -->
<StreamGeometry x:Key="Icon.Server">F0 M3 4 H21 V10 H3 Z M3 14 H21 V20 H3 Z M5.5 6 H7.5 V8 H5.5 Z M5.5 16 H7.5 V18 H5.5 Z</StreamGeometry>
<!-- Icon.Chip — CPU die with hollow core and eight pins -->
<StreamGeometry x:Key="Icon.Chip">F0 M7 7 H17 V17 H7 Z M9.5 9.5 H14.5 V14.5 H9.5 Z M9 3 H10.5 V6.5 H9 Z M13.5 3 H15 V6.5 H13.5 Z M9 17.5 H10.5 V21 H9 Z M13.5 17.5 H15 V21 H13.5 Z M3 9 H6.5 V10.5 H3 Z M3 13.5 H6.5 V15 H3 Z M17.5 9 H21 V10.5 H17.5 Z M17.5 13.5 H21 V15 H17.5 Z</StreamGeometry>
<!-- Icon.Bolt — lightning bolt -->
<StreamGeometry x:Key="Icon.Bolt">M13 2 L4 14 H10 L9 22 L20 9 H13.6 Z</StreamGeometry>
<!-- Icon.Rocket — hull with porthole hole + exhaust -->
<StreamGeometry x:Key="Icon.Rocket">F0 M12 2 C15.5 5 17.5 9.5 17.5 14 V17 L14.5 19 H9.5 L6.5 17 V14 C6.5 9.5 8.5 5 12 2 Z M12 7.5 a2.2 2.2 0 1 0 0 4.4 a2.2 2.2 0 1 0 0-4.4 Z M9.5 19.5 H14.5 L13 22.5 H11 Z</StreamGeometry>
<!-- Icon.Bulb — bulb over two screw-base bands -->
<StreamGeometry x:Key="Icon.Bulb">F0 M12 2 C8.1 2 5 5.1 5 9 C5 11.6 6.4 13.9 8.5 15.1 V17 H15.5 V15.1 C17.6 13.9 19 11.6 19 9 C19 5.1 15.9 2 12 2 Z M8.5 18.2 H15.5 V19.8 H8.5 Z M9.8 20.9 H14.2 V22.3 H9.8 Z</StreamGeometry>
<!-- Icon.Flask — Erlenmeyer flask, single closed outline -->
<StreamGeometry x:Key="Icon.Flask">M9 2 H15 V4 H14 V9.3 L19.4 19.4 A1.5 1.5 0 0 1 18.1 21.6 H5.9 A1.5 1.5 0 0 1 4.6 19.4 L10 9.3 V4 H9 Z</StreamGeometry>
<!-- Icon.Shield — shield with a punched-out check -->
<StreamGeometry x:Key="Icon.Shield">F0 M12 2 L4 5 V11 C4 16 7.4 20.3 12 21.5 C16.6 20.3 20 16 20 11 V5 Z M11 16.2 L7.2 12.4 L8.9 10.7 L11 12.8 L15.4 8.4 L17.1 10.1 Z</StreamGeometry>
<!-- Icon.Camera — body with viewfinder bump and punched-out lens -->
<StreamGeometry x:Key="Icon.Camera">F0 M9 3 H15 L16.5 5.5 H20 A2 2 0 0 1 22 7.5 V19 A2 2 0 0 1 20 21 H4 A2 2 0 0 1 2 19 V7.5 A2 2 0 0 1 4 5.5 H7.5 Z M12 9 a4.5 4.5 0 1 0 0 9 a4.5 4.5 0 1 0 0-9 Z</StreamGeometry>
<!-- Icon.Image — frame with mountains and sun inside the cut-out -->
<StreamGeometry x:Key="Icon.Image">F0 M3 4 H21 V20 H3 Z M5 6 H19 V18 H5 Z M6.5 16.5 L10.5 11 L13.5 14.5 L15.5 12.5 L17.5 16.5 Z M8 8 a1.6 1.6 0 1 0 0 3.2 a1.6 1.6 0 1 0 0-3.2 Z</StreamGeometry>
<!-- Icon.Barcode — six bars of varying width -->
<StreamGeometry x:Key="Icon.Barcode">M3 4 H5 V20 H3 Z M6.5 4 H7.5 V20 H6.5 Z M9 4 H11.5 V20 H9 Z M13 4 H14 V20 H13 Z M15.5 4 H17.5 V20 H15.5 Z M19 4 H21 V20 H19 Z</StreamGeometry>
<!-- Icon.Mail — envelope: frame cut out, flap drawn back inside it -->
<StreamGeometry x:Key="Icon.Mail">F0 M2 5 H22 V19 H2 Z M4 7 H20 V17 H4 Z M4 7 L12 13.5 L20 7 L20 8.7 L12 15.2 L4 8.7 Z</StreamGeometry>
<!-- Icon.Terminal — console frame with prompt chevron and caret -->
<StreamGeometry x:Key="Icon.Terminal">F0 M2 4 H22 V20 H2 Z M4 6 H20 V18 H4 Z M6 8.4 L7.4 7 L11.4 11 L7.4 15 L6 13.6 L8.6 11 Z M12.5 13.4 H17 V15.2 H12.5 Z</StreamGeometry>
<!-- Icon.Code — angle brackets around a slash -->
<StreamGeometry x:Key="Icon.Code">M8.6 5.4 L10 6.8 L5.8 11 L10 15.2 L8.6 16.6 L3 11 Z M15.4 5.4 L21 11 L15.4 16.6 L14 15.2 L18.2 11 L14 6.8 Z M13.6 3.4 L15.4 3.9 L10.4 20.6 L8.6 20.1 Z</StreamGeometry>
<!-- Icon.Database — cylinder with the top ellipse punched out. Béziers, not arcs: an arc
spanning exactly its own diameter degenerates to a straight line here (measured). -->
<StreamGeometry x:Key="Icon.Database">F0 M5.3 5.2 C5.3 3.43 8.3 2 12 2 C15.7 2 18.7 3.43 18.7 5.2 V18.8 C18.7 20.57 15.7 22 12 22 C8.3 22 5.3 20.57 5.3 18.8 Z M5.3 5.2 C5.3 3.43 8.3 2 12 2 C15.7 2 18.7 3.43 18.7 5.2 C18.7 6.97 15.7 8.4 12 8.4 C8.3 8.4 5.3 6.97 5.3 5.2 Z</StreamGeometry>
<!-- Icon.Wand — wand shaft plus three sparkles (kept clear of the shaft) -->
<StreamGeometry x:Key="Icon.Wand">M3.3 19.3 L13.3 9.3 L14.7 10.7 L4.7 20.7 Z M18 2.5 L19.1 5.9 L22.5 7 L19.1 8.1 L18 11.5 L16.9 8.1 L13.5 7 L16.9 5.9 Z M6 3 L6.7 4.8 L8.5 5.5 L6.7 6.2 L6 8 L5.3 6.2 L3.5 5.5 L5.3 4.8 Z M19.5 15 L20.2 16.8 L22 17.5 L20.2 18.2 L19.5 20 L18.8 18.2 L17 17.5 L18.8 16.8 Z</StreamGeometry>
<!-- Icon.Clock — ring with hands; the hands only touch at x=12.8, never overlap (EvenOdd).
Béziers, not arcs: see Icon.Database. -->
<StreamGeometry x:Key="Icon.Clock">F0 M2 12 C2 6.48 6.48 2 12 2 C17.52 2 22 6.48 22 12 C22 17.52 17.52 22 12 22 C6.48 22 2 17.52 2 12 Z M4 12 C4 7.58 7.58 4 12 4 C16.42 4 20 7.58 20 12 C20 16.42 16.42 20 12 20 C7.58 20 4 16.42 4 12 Z M11.2 6.5 H12.8 V12.8 H11.2 Z M12.8 11.2 H16.5 V12.8 H12.8 Z</StreamGeometry>
<!-- Badge brushes -->
<SolidColorBrush x:Key="DraftBadgeBrush" Color="{StaticResource TextMuteColor}"/>
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="{StaticResource PeatColor}"/>
@@ -789,6 +862,11 @@
<Setter Property="Background" Value="{StaticResource AccentSoftBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
</Style>
<!-- Resting icon colour. Must live here, not inline on the PathIcon: a local value outranks
every style, so an inline Foreground makes the .active accent below unreachable. -->
<Style Selector="Border.list-item PathIcon.list-icon">
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
</Style>
<!-- Active item text / icon colors -->
<Style Selector="Border.list-item.active TextBlock.list-label">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
@@ -295,7 +295,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private bool _suppressTitleSave;
private CancellationTokenSource? _titleSaveCts;
// Set by shell so CloseDetailCommand can clear SelectedTask
// Set by shell so deleting a task can clear SelectedTask
public Action? CloseDetail { get; set; }
// Set by shell so DeleteTaskCommand can remove from list
@@ -1008,9 +1008,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
}
}
[RelayCommand]
private void CloseDetails() => CloseDetail?.Invoke();
[RelayCommand]
private async System.Threading.Tasks.Task ToggleStarAsync()
{
@@ -24,6 +24,6 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
// Set while a dragged task hovers over this row as a move target — distinct from
// DropHintAbove/Below, which mark an insertion point for list reordering.
[ObservableProperty] private bool _isTaskDropTarget;
public string? IconKey { get; init; }
public string? DotColorKey { get; init; }
// Settable (not init) so a list-settings icon change refreshes the row in place.
[ObservableProperty] private string? _iconKey;
}
@@ -54,7 +54,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
if (row is null || Dialogs is null || _services is null) return;
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked);
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked, row.IconKey);
await Dialogs.ShowListSettingsAsync(vm);
if (vm.Deleted) await LoadAsync();
else await RefreshRowAsync(row.Id);
@@ -293,8 +293,6 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var lists = new ListRepository(ctx);
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
var dotColors = new[] { "Moss", "Peat", "Sage" };
int idx = 0;
foreach (var l in await lists.GetAllAsync(ct))
{
if (seedNames.Contains(l.Name)) continue;
@@ -304,8 +302,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
Id = $"user:{l.Id}",
Name = l.Name,
Kind = ListKind.User,
IconKey = "Folder",
DotColorKey = dotColors[idx % dotColors.Length],
IconKey = l.Icon ?? "Folder",
WorkingDir = l.WorkingDir,
DefaultCommitType = l.DefaultCommitType,
IsManual = l.IsManual,
@@ -314,7 +311,6 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
};
Items.Add(item);
UserLists.Add(item);
idx++;
}
ok = true;
}
@@ -395,7 +391,6 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
Name = entity.Name,
Kind = ListKind.User,
IconKey = "Folder",
DotColorKey = "Moss",
WorkingDir = entity.WorkingDir,
DefaultCommitType = entity.DefaultCommitType,
};
@@ -407,7 +402,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
if (Dialogs is not null && _services is not null)
{
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked);
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon);
await Dialogs.ShowListSettingsAsync(vm);
if (vm.Deleted) await LoadAsync();
else await RefreshRowAsync(item.Id);
@@ -507,6 +502,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
row.DefaultCommitType = entity.DefaultCommitType;
row.IsManual = entity.IsManual;
row.FindingsTracked = entity.FindingsTracked;
row.IconKey = entity.Icon ?? "Folder";
row.HasTicketProject = hasTicketProject;
RecomputeHasNoLinkedRepo();
}
@@ -547,14 +543,12 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
/// </summary>
private async Task AddRowAsync(ListRepository lists, ListEntity entity, bool hasTicketProject = false)
{
var dotColors = new[] { "Moss", "Peat", "Sage" };
var item = new ListNavItemViewModel
{
Id = $"user:{entity.Id}",
Name = entity.Name,
Kind = ListKind.User,
IconKey = "Folder",
DotColorKey = dotColors[UserLists.Count % dotColors.Length],
IconKey = entity.Icon ?? "Folder",
WorkingDir = entity.WorkingDir,
DefaultCommitType = entity.DefaultCommitType,
IsManual = entity.IsManual,
@@ -58,10 +58,20 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
public bool HasPanes => Panes.Count > 0;
// Read-only view of the worker queue (tasks waiting to run), shown as a side strip.
// Read-only activity strip. Beyond the queue itself (Queued/Running) it also carries the rows
// that want the user's attention (Failed, WaitingForReview, roadblocked) — colour-coded only;
// clicking a row opens the task in the main app, where the actual actions live.
public ObservableCollection<QueuedTaskViewModel> Queued { get; } = new();
public bool HasQueued => Queued.Count > 0;
// "New session" targets: one entry per list with a working dir, plus a browse fallback, so a
// fresh session is two clicks instead of navigating a folder picker.
public ObservableCollection<SessionTargetViewModel> SessionTargets { get; } = new();
// View-layer seam: the folder picker needs a TopLevel, so the view owns it (see
// MissionControlView.OnNewSessionClicked).
public Action? BrowseForSessionRequested { get; set; }
public UsagePillViewModel UsagePill { get; }
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, UsagePillViewModel usagePill)
@@ -89,6 +99,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
_worker.HandoffRequestedEvent += _onHandoffRequested;
_ = RefreshQueueAsync();
_ = RefreshSessionTargetsAsync();
}
internal async System.Threading.Tasks.Task RefreshQueueAsync()
@@ -98,23 +109,33 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
await using var ctx = await _dbFactory.CreateDbContextAsync();
var rows = await ctx.Tasks.AsNoTracking()
.Where(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Queued
|| t.Status == ClaudeDo.Data.Models.TaskStatus.Running)
.OrderBy(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Running ? 0 : 1)
|| t.Status == ClaudeDo.Data.Models.TaskStatus.Running
|| t.Status == ClaudeDo.Data.Models.TaskStatus.WaitingForReview
|| t.Status == ClaudeDo.Data.Models.TaskStatus.Failed)
// Attention first (failed, then review), then the queue itself.
.OrderBy(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Failed ? 0
: t.Status == ClaudeDo.Data.Models.TaskStatus.WaitingForReview ? 1
: t.Status == ClaudeDo.Data.Models.TaskStatus.Running ? 2 : 3)
.ThenBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.Select(t => new { t.Id, t.Title, t.BlockedByTaskId, t.Status })
.Select(t => new { t.Id, t.Title, t.BlockedByTaskId, t.Status, t.RoadblockCount })
.ToListAsync();
Queued.Clear();
foreach (var r in rows)
{
var id = r.Id;
var running = r.Status == ClaudeDo.Data.Models.TaskStatus.Running;
var (tint, colorKey, labelKey) = Decorate(r.Status, r.RoadblockCount, r.BlockedByTaskId != null);
Queued.Add(new QueuedTaskViewModel
{
Id = id,
Title = r.Title ?? string.Empty,
IsBlocked = r.BlockedByTaskId != null,
IsRunning = r.Status == ClaudeDo.Data.Models.TaskStatus.Running,
IsRunning = running,
TintKey = tint,
StatusColorKey = colorKey,
StatusLabel = labelKey is null ? null : Loc.T(labelKey),
OpenInAppCommand = new RelayCommand(() => OpenInApp?.Invoke(id)),
OpenSessionCommand = new RelayCommand(() => _ = OpenConPtySessionAsync(id)),
});
}
OnPropertyChanged(nameof(HasQueued));
@@ -122,6 +143,54 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
}
// Row tint + status label for the activity strip. Keys are resolved to brushes by
// DotBrushConverter ("<key>Brush"), so they must match tokens in Tokens.axaml.
private static (string? Tint, string? ColorKey, string? LabelKey) Decorate(
ClaudeDo.Data.Models.TaskStatus status, int roadblockCount, bool blocked)
=> status switch
{
ClaudeDo.Data.Models.TaskStatus.Failed => ("ErrorTint", "StatusError", "missionControl.failed"),
ClaudeDo.Data.Models.TaskStatus.WaitingForReview when roadblockCount > 0
=> ("RoadblockTint", "Amber", "missionControl.roadblock"),
ClaudeDo.Data.Models.TaskStatus.WaitingForReview => ("DoneTint", "StatusReview", "missionControl.review"),
ClaudeDo.Data.Models.TaskStatus.Running => ("RunningTint", "StatusRunning", "missionControl.running"),
_ when blocked => (null, "Amber", "missionControl.blocked"),
_ => (null, null, null),
};
// Rebuilt on every flyout open — lists change rarely, but a stale target would launch a
// session in a directory that is no longer configured.
internal async System.Threading.Tasks.Task RefreshSessionTargetsAsync()
{
SessionTargets.Clear();
// Browse goes in first so the menu stays usable even if the list query below fails.
SessionTargets.Add(new SessionTargetViewModel
{
Label = Loc.T("missionControl.browseForFolder"),
StartCommand = new RelayCommand(() => BrowseForSessionRequested?.Invoke()),
});
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = await ctx.Lists.AsNoTracking()
.Where(l => l.WorkingDir != null && l.WorkingDir != "")
.OrderBy(l => l.SortOrder).ThenBy(l => l.Name)
.Select(l => new { l.Name, l.WorkingDir })
.ToListAsync();
foreach (var l in lists)
{
var dir = l.WorkingDir!;
SessionTargets.Add(new SessionTargetViewModel
{
Label = l.Name,
StartCommand = new RelayCommand(() => _ = OpenAdHocConPtySessionAsync(dir)),
});
}
}
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
}
// Drop-to-queue: a task dragged from the main app onto Mission Control gets queued. Goes
// through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write so the
// manual/draft-child guards apply here too. The interactive-session check has to live here
@@ -445,12 +514,28 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
}
}
/// <summary>Read-only display row for a queued or running task in the Mission Control side strip.</summary>
/// <summary>Display row in the Mission Control activity strip (queued, running, or wanting
/// attention). Read-only apart from opening the task in the app / in a session.</summary>
public sealed class QueuedTaskViewModel
{
public required string Id { get; init; }
public required string Title { get; init; }
public bool IsBlocked { get; init; }
public bool IsRunning { get; init; }
/// <summary>Brush key of the row tint (e.g. "RunningTint"); null = untinted.</summary>
public string? TintKey { get; init; }
public string? TintBorderKey => TintKey is null ? null : TintKey + "Border";
public string? StatusLabel { get; init; }
public string? StatusColorKey { get; init; }
/// <summary>A running task's worktree belongs to the autonomous run — never open a second,
/// interactive claude process into it.</summary>
public bool CanOpenSession => !IsRunning;
public IRelayCommand? OpenInAppCommand { get; init; }
public IRelayCommand? OpenSessionCommand { get; init; }
}
/// <summary>One entry in the "new session" menu: a configured list's working dir, or browse.</summary>
public sealed class SessionTargetViewModel
{
public required string Label { get; init; }
public required IRelayCommand StartCommand { get; init; }
}
@@ -42,12 +42,34 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
// lands; a non-zero exit keeps the task out of Done instead of silently reporting merged.
[ObservableProperty] private string _verifyCommand = "";
// Dropdown hidden entirely when no ticket-system base URL is configured (Settings → Files tab).
// Icon key into the UI icon library (IconKeyConverter -> "Icon.<key>" StreamGeometry).
[ObservableProperty] private string _icon = DefaultIcon;
[ObservableProperty] private bool _ticketsAvailable;
[ObservableProperty] private TicketProjectOption? _selectedTicketProject;
public ObservableCollection<TicketProjectOption> TicketProjects { get; } = new();
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
public const string DefaultIcon = "Folder";
// Curated subset of the Icon.* geometry library in IslandStyles.axaml — window chrome and
// pure stroke icons (PathIcon fills, so those render invisible) are deliberately left out.
// Grouped by theme so the picker grid reads in rows; every entry needs a matching
// "Icon.<key>" StreamGeometry or it renders as an empty slot.
public static readonly string[] IconOptions =
[
// general
"Folder", "Star", "Sun", "Calendar", "Clock", "Inbox", "Mail", "Text", "Search", "Grid",
// dev
"Code", "Terminal", "Database", "Server", "Chip", "Bolt", "Wand", "Flask", "Shield",
// status / flow
"Activity", "Eye", "Check", "Warning", "Chart", "Rocket", "Bulb", "Broom", "AgentSuggested", "Settings",
// things
"Box", "Truck", "MapPin", "Camera", "Image", "Barcode", "Skull",
];
public ObservableCollection<string> Icons { get; } = new(IconOptions);
// The shared agent-config editor (Model / MaxTurns / SystemPrompt / AgentFile),
// scoped to this list (list → global inheritance).
public AgentConfigEditorViewModel Agent { get; }
@@ -68,12 +90,16 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
string defaultCommitType,
bool isManual = false,
bool findingsTracked = false,
string? icon = null,
CancellationToken ct = default)
{
ListId = listId;
Name = name;
IsManual = isManual;
FindingsTracked = findingsTracked;
// An unknown/legacy key would leave the picker with no selection and silently reset the
// icon on the next save — fall back to the default instead.
Icon = IconOptions.Contains(icon) ? icon! : DefaultIcon;
WorkingDir = workingDir ?? "";
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
@@ -111,7 +137,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
DefaultCommitType,
IsManual,
FindingsTracked));
FindingsTracked,
Icon));
// Tri-state: dropdown never shown (no base URL) -> null, leave the stored link alone.
// Dropdown shown -> always an explicit value, 0 for "— none —" to actively clear it.
@@ -10,12 +10,15 @@
<!-- Column 0: id badge + editable title -->
<StackPanel Grid.Column="0" Spacing="0">
<TextBlock Classes="meta"
Text="{Binding TaskIdBadge}"
Margin="0,0,0,4"
Cursor="Hand"
ToolTip.Tip="{loc:Tr details.copyTaskIdTip}"
Tapped="OnTaskIdTapped"/>
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
<TextBlock Classes="meta"
Text="{Binding TaskIdBadge}"
Cursor="Hand"
ToolTip.Tip="{loc:Tr details.copyTaskIdTip}"
Tapped="OnTaskIdTapped"/>
<!-- created-at moved up from the removed metadata footer -->
<TextBlock Classes="meta" Text="{Binding Task.CreatedAtFormatted}"/>
</StackPanel>
<TextBox Text="{Binding EditableTitle, Mode=TwoWay}"
Background="Transparent"
BorderThickness="0"
@@ -11,27 +11,6 @@
<Panel>
<DockPanel>
<!-- ── Metadata footer (sticky bottom) — created-at + close — task detail only ── -->
<Border DockPanel.Dock="Bottom"
IsVisible="{Binding IsTaskDetailVisible}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0"
Padding="14,8">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0"
Classes="meta"
Text="{Binding Task.CreatedAtFormatted}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="icon-btn"
Command="{Binding CloseDetailsCommand}"
ToolTip.Tip="{loc:Tr details.closeTip}"
VerticalAlignment="Center">
<PathIcon Data="{StaticResource Icon.X}" Width="14" Height="14"/>
</Button>
</Grid>
</Border>
<!-- ── Header (sticky top): id · title · trash/skull · gear — task detail only ── -->
<Border DockPanel.Dock="Top" Classes="island-header"
IsVisible="{Binding IsTaskDetailVisible}">
@@ -103,7 +103,6 @@
<PathIcon Grid.Column="0" Classes="list-icon"
Width="14" Height="14"
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
Foreground="{DynamicResource TextMuteBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Name -->
<TextBlock Grid.Column="1" Classes="list-label"
@@ -177,11 +176,11 @@
HorizontalAlignment="Left" VerticalAlignment="Center"
Margin="-8,0,0,0"/>
</Border>
<!-- Color dot (6px circle, color from DotColorKey) -->
<Ellipse Grid.Column="0"
Width="6" Height="6"
Fill="{Binding DotColorKey, Converter={StaticResource DotBrush}}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Icon (list-settings → Icon tab; "Folder" when unset) -->
<PathIcon Grid.Column="0" Classes="list-icon"
Width="14" Height="14"
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Name -->
<TextBlock Grid.Column="1" Classes="list-label"
Text="{Binding Name}"
@@ -4,6 +4,7 @@
xmlns:vmm="using:ClaudeDo.Ui.ViewModels.MissionControl"
xmlns:mc="using:ClaudeDo.Ui.Views.MissionControl"
xmlns:controls="using:ClaudeDo.Ui.Views.Controls"
xmlns:conv="using:ClaudeDo.Ui.Converters"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:DataType="vm:MissionControlViewModel"
x:Class="ClaudeDo.Ui.Views.MissionControl.MissionControlView">
@@ -33,9 +34,19 @@
Margin="0,0,4,0"
VerticalAlignment="Center"/>
<Button Classes="icon-btn"
Click="OnNewSessionClicked"
ToolTip.Tip="{loc:Tr missionControl.newSession}">
<PathIcon Data="{StaticResource Icon.Plus}" Width="15" Height="15"/>
<Button.Flyout>
<MenuFlyout ItemsSource="{Binding SessionTargets}" Opening="OnSessionFlyoutOpening">
<MenuFlyout.ItemContainerTheme>
<ControlTheme TargetType="MenuItem" BasedOn="{StaticResource {x:Type MenuItem}}"
x:DataType="vm:SessionTargetViewModel">
<Setter Property="Header" Value="{Binding Label}"/>
<Setter Property="Command" Value="{Binding StartCommand}"/>
</ControlTheme>
</MenuFlyout.ItemContainerTheme>
</MenuFlyout>
</Button.Flyout>
</Button>
<Button Classes="icon-btn"
Command="{Binding ToggleLayoutCommand}"
@@ -100,7 +111,7 @@
BorderThickness="1,0,0,0">
<DockPanel LastChildFill="True" Margin="10,10">
<TextBlock DockPanel.Dock="Top" Classes="eyebrow"
Text="{loc:Tr missionControl.queue}"
Text="{loc:Tr missionControl.activity}"
Foreground="{DynamicResource TextMuteBrush}"
LetterSpacing="1.4" Margin="0,0,0,8" />
<ScrollViewer>
@@ -112,27 +123,31 @@
Background="Transparent" BorderThickness="0"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
Cursor="Hand">
<Button.ContextMenu>
<ContextMenu>
<MenuItem Header="{loc:Tr missionControl.openSession}"
Command="{Binding OpenSessionCommand}"
IsEnabled="{Binding CanOpenSession}" />
</ContextMenu>
</Button.ContextMenu>
<Panel>
<Border Background="{DynamicResource SurfaceBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1" CornerRadius="6" />
<Border Background="{DynamicResource RunningTintBrush}"
BorderBrush="{DynamicResource RunningTintBorderBrush}"
<!-- Single state tint; brush keys come from the VM (Tokens.axaml). -->
<Border Background="{Binding TintKey, Converter={x:Static conv:DotBrushConverter.Instance}}"
BorderBrush="{Binding TintBorderKey, Converter={x:Static conv:DotBrushConverter.Instance}}"
BorderThickness="1" CornerRadius="6"
IsVisible="{Binding IsRunning}" />
IsVisible="{Binding TintKey, Converter={x:Static ObjectConverters.IsNotNull}}" />
<StackPanel Margin="8,6" Spacing="2">
<TextBlock Text="{Binding Title}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Title}"
Foreground="{DynamicResource TextDimBrush}" />
<TextBlock Classes="meta"
Text="{loc:Tr missionControl.blocked}"
IsVisible="{Binding IsBlocked}"
Foreground="{DynamicResource AmberBrush}" />
<TextBlock Classes="meta"
Text="{loc:Tr missionControl.running}"
IsVisible="{Binding IsRunning}"
Foreground="{DynamicResource StatusRunningBrush}" />
Text="{Binding StatusLabel}"
IsVisible="{Binding StatusLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Foreground="{Binding StatusColorKey, Converter={x:Static conv:DotBrushConverter.Instance}}" />
</StackPanel>
</Panel>
</Button>
@@ -3,7 +3,6 @@ using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform.Storage;
@@ -30,8 +29,10 @@ public partial class MissionControlView : UserControl
QueueSplitter.DragCompleted += (_, _) => _queueWidth = BodyGrid.ColumnDefinitions[2].Width;
}
private void OnSessionFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshSessionTargetsAsync();
// Ad-hoc ConPTY session: the view owns the folder picker, the VM only takes the chosen path.
private async void OnNewSessionClicked(object? sender, RoutedEventArgs e)
private async void BrowseForSessionAsync()
{
if (DataContext is not MissionControlViewModel vm) return;
var topLevel = TopLevel.GetTopLevel(this);
@@ -53,6 +54,7 @@ public partial class MissionControlView : UserControl
{
_vm.Panes.CollectionChanged -= OnPanesChanged;
_vm.PropertyChanged -= OnViewModelPropertyChanged;
_vm.BrowseForSessionRequested = null;
}
_vm = DataContext as MissionControlViewModel;
@@ -61,6 +63,7 @@ public partial class MissionControlView : UserControl
{
_vm.Panes.CollectionChanged += OnPanesChanged;
_vm.PropertyChanged += OnViewModelPropertyChanged;
_vm.BrowseForSessionRequested = BrowseForSessionAsync;
}
RebuildOverviewGrid();
@@ -39,6 +39,7 @@
Background="Transparent" Margin="12,16,8,8"
VerticalAlignment="Top">
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionGeneral}"/>
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionIcon}"/>
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionAgent}"/>
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionVerify}"/>
</ListBox>
@@ -113,6 +114,35 @@
</ScrollViewer>
</TabItem>
<!-- ICON -->
<TabItem Header="{loc:Tr modals.listSettings.sectionIcon}">
<ScrollViewer>
<StackPanel Spacing="8" Margin="0,8,0,0">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.icon}"/>
<ListBox ItemsSource="{Binding Icons}"
SelectedItem="{Binding Icon, Mode=TwoWay}"
Background="Transparent" BorderThickness="0"
HorizontalAlignment="Left">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="x:String">
<!-- No Foreground here on purpose: the icon inherits the container's, so the
selected/hover states of ListBoxItem reach it (see Ui/CLAUDE.md). -->
<PathIcon Width="20" Height="20" Margin="6"
Data="{Binding Converter={StaticResource IconKey}}"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Text="{loc:Tr modals.listSettings.iconHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
</ScrollViewer>
</TabItem>
<!-- AGENT -->
<TabItem Header="{loc:Tr modals.listSettings.sectionAgent}">
<ScrollViewer>
+1
View File
@@ -543,6 +543,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
entity.DefaultCommitType = string.IsNullOrWhiteSpace(dto.DefaultCommitType) ? CommitTypeRegistry.DefaultType : dto.DefaultCommitType;
entity.IsManual = dto.IsManual;
entity.FindingsTracked = dto.FindingsTracked;
entity.Icon = string.IsNullOrWhiteSpace(dto.Icon) ? null : dto.Icon;
await repo.UpdateAsync(entity);
await _broadcaster.ListUpdated(dto.Id);
@@ -986,6 +986,48 @@ public sealed class TaskMergeService
if (task.Status != TaskStatus.WaitingForReview)
return Blocked("task is not waiting for review");
if (wt is not null && wt.State == WorktreeState.Kept)
{
// Kept means either RevertMergeAsync undid a previous merge, or the user manually
// parked an active worktree -- either way its branch may still exist. Re-running the
// real merge (not the "nothing to merge" no-op below) is the only way approve can be
// trusted after a revert; if the branch is truly gone (the common case once the
// original approve already deleted it via removeWorktree:true) there is nothing left
// to merge and that must come back as Blocked, never a silent fake "merged".
if (string.IsNullOrWhiteSpace(list.WorkingDir) || !await _git.IsGitRepoAsync(list.WorkingDir, ct))
return Blocked("list has no working directory");
var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct);
if (!branches.Contains(wt.BranchName, StringComparer.Ordinal))
return Blocked(
$"worktree was reverted and its branch '{wt.BranchName}' no longer exists — cannot re-approve; discard the task or start a fresh worktree instead.");
var reviveTarget = string.IsNullOrWhiteSpace(targetBranch)
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
: targetBranch;
// A branch that was already merged once (reverted or not) is already an ancestor of
// the target tip -- `git merge` on it again is a silent "Already up to date." no-op,
// since merge only checks ancestry, never content. Detect that case up front and
// reapply the original merge commit's own diff instead of falling into the normal
// merge path below, which would report success without actually restoring anything.
var branchTip = await _git.RevParseAsync(list.WorkingDir, wt.BranchName, ct);
var targetTip = await _git.RevParseAsync(list.WorkingDir, reviveTarget, ct);
if (await _git.IsAncestorAsync(list.WorkingDir, branchTip, targetTip, ct) == true)
{
if (string.IsNullOrWhiteSpace(wt.MergeCommit))
return Blocked("worktree branch has no new commits to merge and no merge commit is recorded to reapply");
return await ReapplyRevertedMergeAsync(
task, list, wt, reviveTarget, verifyCommand, ct, progress, skipVerify);
}
using (var ctx = _dbFactory.CreateDbContext())
await new WorktreeRepository(ctx).SetStateAsync(taskId, WorktreeState.Active, ct);
await _broadcaster.WorktreeUpdated(taskId);
wt.State = WorktreeState.Active;
}
if (wt is null || wt.State != WorktreeState.Active)
{
// There is nothing left to merge -- a sandbox run, or a list-handler task that
@@ -1031,6 +1073,107 @@ public sealed class TaskMergeService
return await MergeAsync(taskId, target, removeWorktree: true, commitMessage: "", leaveConflictsInTree, ct, progress, skipVerify);
}
/// <summary>
/// Redoes a merge that <see cref="RevertMergeAsync"/> undid (or that predates it, if the branch
/// was already merged before its worktree reached <see cref="WorktreeState.Kept"/>), by
/// cherry-picking the original merge commit's own diff onto <paramref name="targetBranch"/> --
/// see <see cref="GitService.CherryPickMergeCommitAsync"/> for why a plain re-merge can't be
/// used once the branch tip is already an ancestor of the target. Mirrors <see cref="MergeAsync"/>'s
/// shape (gate, dirty/mid-op checks, cleanup, verify gate, approve) but produces a cherry-pick
/// commit instead of a merge commit, and <see cref="WorktreeRepository.SetMergedAsync"/> still
/// records it as this task's <see cref="WorktreeEntity.MergeCommit"/>.
/// </summary>
private async Task<MergeResult> ReapplyRevertedMergeAsync(
TaskEntity task, ListEntity list, WorktreeEntity wt, string targetBranch, string? verifyCommand,
CancellationToken ct, IProgress<ProgressNotificationValue>? progress, bool skipVerify)
{
var taskId = task.Id;
var workingDir = list.WorkingDir!;
await _broadcaster.OperationProgress(taskId, PhaseMerging, 0, 0);
var gate = GetMergeGate(workingDir);
await gate.WaitAsync(ct);
try
{
if (await _git.IsMidMergeAsync(workingDir, ct))
return Blocked("target working directory is mid-merge");
if (await _git.IsMidCherryPickAsync(workingDir, ct))
return Blocked("target working directory is mid-cherry-pick");
if (await _git.HasChangesAsync(workingDir, includeUntracked: false, ct))
return Blocked("target working tree has uncommitted changes");
var currentBranch = await _git.GetCurrentBranchAsync(workingDir, ct);
if (!string.Equals(currentBranch, targetBranch, StringComparison.Ordinal))
{
try { await _git.CheckoutBranchAsync(workingDir, targetBranch, ct); }
catch (Exception ex) { return Blocked($"failed to switch target branch: {ex.Message}"); }
}
var oldTargetTip = await _git.RevParseHeadAsync(workingDir, ct);
var (exitCode, stderr) = await _git.CherryPickMergeCommitAsync(workingDir, wt.MergeCommit!, ct);
if (exitCode != 0)
{
try { await _git.CherryPickAbortAsync(workingDir, ct); }
catch (Exception ex)
{
_logger.LogError(ex, "git cherry-pick --abort failed after conflict — repo is mid-cherry-pick");
return Blocked($"reapply conflict and abort failed: {ex.Message} — repo is mid-cherry-pick, resolve manually");
}
return Blocked($"reapplying the reverted merge conflicted and was aborted: {stderr}");
}
var mergeSha = await _git.RevParseHeadAsync(workingDir, ct);
string? cleanupWarning = null;
try
{
await _git.WorktreeRemoveAsync(workingDir, wt.Path, force: false, ct);
try { await _git.BranchDeleteAsync(workingDir, wt.BranchName, force: false, ct); }
catch (Exception ex)
{
_logger.LogWarning(ex, "branch delete failed for {Branch}", wt.BranchName);
cleanupWarning = $"worktree removed, branch delete failed: {ex.Message}";
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "worktree remove failed for {Path}", wt.Path);
cleanupWarning = $"worktree remove failed: {ex.Message}";
}
await MarkWorktreeMergedAsync(taskId, mergeSha, ct);
await RebaseOthersAfterMergeAsync(task, list, targetBranch, oldTargetTip, mergeSha, ct);
if (!string.IsNullOrWhiteSpace(verifyCommand) && skipVerify)
{
await _broadcaster.WorkerLog(
$"Re-merged #{task.Number} \"{task.Title}\" into {targetBranch} after a revert — verify deferred to batch",
WorkerLogLevel.Info, DateTime.UtcNow);
return new MergeResult(StatusMergedVerifyPending, Array.Empty<string>(), cleanupWarning);
}
var verifyFailure = await RunVerifyGateAsync(
verifyCommand, workingDir, ct, progress,
elapsed => _ = _broadcaster.OperationProgress(taskId, PhaseVerifying, (int)elapsed.TotalSeconds, 0));
if (verifyFailure is not null)
{
_logger.LogWarning("Verify command failed after reapplying reverted merge for task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage);
await _broadcaster.WorkerLog($"Verify failed for #{task.Number} \"{task.Title}\" after re-merge into {targetBranch}", WorkerLogLevel.Warn, DateTime.UtcNow);
return verifyFailure;
}
await ApproveIfWaitingForReviewAsync(task, ct);
_logger.LogInformation(
"Reapplied reverted merge for task {TaskId} branch {Branch} into {Target}",
taskId, wt.BranchName, targetBranch);
await _broadcaster.WorkerLog($"Re-merged #{task.Number} \"{task.Title}\" into {targetBranch} after a revert", WorkerLogLevel.Success, DateTime.UtcNow);
return new MergeResult(StatusMerged, Array.Empty<string>(), cleanupWarning);
}
finally { gate.Release(); }
}
/// <summary>
/// The batch counterpart to the per-merge verify gate: runs the list's verify command ONCE
/// (under the same per-repo gate) and, on success, promotes every listed task to Done via
@@ -7,4 +7,9 @@ namespace ClaudeDo.Worker.Planning;
public interface IActiveMergeState
{
bool HasActiveMerge(string taskId);
// In-memory state above is empty after a worker restart. This falls back to an on-disk
// check (repo mid-merge) for the one case that matters: a unit-merge parent paused on a
// conflict when the process died mid-drain.
Task<bool> HasActiveMergeAsync(string taskId, CancellationToken ct);
}
@@ -164,6 +164,31 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
public bool HasActiveMerge(string parentTaskId) =>
_states.TryGetValue(parentTaskId, out var s) && (s.CurrentSubtaskId is not null || s.IsFinalizing);
/// <summary>Stateless fallback for <see cref="HasActiveMerge"/>: after a worker restart
/// <see cref="_states"/> is empty, so a unit-merge parent paused on a conflict would look
/// cancellable when its repo is still mid-merge. Only a WaitingForReview task with children
/// (i.e. a would-be merge parent) triggers the on-disk check — an ordinary task in the same
/// list must stay cancellable even while the list's repo is mid-merge.</summary>
public async Task<bool> HasActiveMergeAsync(string taskId, CancellationToken ct)
{
if (HasActiveMerge(taskId)) return true;
string? workingDir;
bool hasChildren;
await using (var ctx = _dbFactory.CreateDbContext())
{
var task = await ctx.Tasks
.Include(t => t.List)
.SingleOrDefaultAsync(t => t.Id == taskId, ct);
if (task is null || task.Status != TaskStatus.WaitingForReview) return false;
workingDir = task.List.WorkingDir;
hasChildren = await ctx.Tasks.AnyAsync(c => c.ParentTaskId == taskId, ct);
}
if (!hasChildren || string.IsNullOrWhiteSpace(workingDir)) return false;
return await _git.IsMidMergeAsync(workingDir, ct);
}
/// <summary>Externally-driven unit merges currently paused on a conflict, so the UI can
/// recover this on reconnect instead of relying solely on the one-shot broadcast. Checked
/// against <see cref="GitService.IsMidMergeAsync"/> rather than trusting the in-memory flag
@@ -286,7 +286,7 @@ public sealed class TaskStateService : ITaskStateService
// from under it would flip the parent to Cancelled while the orchestrator keeps merging —
// FinalizeParentDoneAsync then finds the parent no longer WaitingForReview and gives up,
// leaving the merged children's diffs stranded with no way to roll them back.
if (_mergeState().HasActiveMerge(taskId))
if (await _mergeState().HasActiveMergeAsync(taskId, ct))
return new TransitionResult(false, "A merge is in progress for this task; wait for it to finish before cancelling.");
List<string> cancelledChildIds;
@@ -202,6 +202,66 @@ public class MissionControlViewModelTests : IDisposable
Assert.Equal(new[] { "first", "second" }, vm.Queued.Select(q => q.Title).ToArray());
}
// ── activity strip: attention rows come first and carry a tint ─────────────
[Fact]
public async Task RefreshQueueAsync_IncludesAttentionRows_AttentionFirst_WithTint()
{
await using (var db = NewContext())
{
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "q1", ListId = "L1", Title = "queued", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "run1", ListId = "L1", Title = "running", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 1 });
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "rev1", ListId = "L1", Title = "review", Status = TaskStatus.WaitingForReview, CreatedAt = DateTime.UtcNow, SortOrder = 2 });
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "rb1", ListId = "L1", Title = "roadblocked", Status = TaskStatus.WaitingForReview, RoadblockCount = 2, CreatedAt = DateTime.UtcNow, SortOrder = 3 });
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "f1", ListId = "L1", Title = "failed", Status = TaskStatus.Failed, CreatedAt = DateTime.UtcNow, SortOrder = 4 });
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "done1", ListId = "L1", Title = "done", Status = TaskStatus.Done, CreatedAt = DateTime.UtcNow, SortOrder = 5 });
await db.SaveChangesAsync();
}
using var vm = BuildVm(new FakeWorker());
await vm.RefreshQueueAsync();
// Done is not activity; the other five are, ordered failed → review → running → queued.
Assert.Equal(new[] { "f1", "rev1", "rb1", "run1", "q1" }, vm.Queued.Select(q => q.Id).ToArray());
Assert.Equal("ErrorTint", vm.Queued[0].TintKey);
Assert.Equal("DoneTint", vm.Queued[1].TintKey);
Assert.Equal("RoadblockTint", vm.Queued[2].TintKey); // roadblocks outrank plain review
Assert.Equal("RunningTint", vm.Queued[3].TintKey);
Assert.Null(vm.Queued[4].TintKey);
Assert.Equal("RunningTintBorder", vm.Queued[3].TintBorderKey);
// A running task's worktree belongs to the autonomous run — no interactive session on it.
Assert.False(vm.Queued[3].CanOpenSession);
Assert.True(vm.Queued[4].CanOpenSession);
}
[Fact]
public async Task RefreshSessionTargetsAsync_ListsWithWorkingDir_PlusBrowse()
{
await using (var db = NewContext())
{
db.Lists.Add(new ListEntity { Id = "L1", Name = "Alpha", WorkingDir = @"C:\repos\alpha", CreatedAt = DateTime.UtcNow, SortOrder = 1 });
db.Lists.Add(new ListEntity { Id = "L2", Name = "Beta", WorkingDir = @"C:\repos\beta", CreatedAt = DateTime.UtcNow, SortOrder = 0 });
db.Lists.Add(new ListEntity { Id = "L3", Name = "NoDir", CreatedAt = DateTime.UtcNow, SortOrder = 2 });
await db.SaveChangesAsync();
}
using var vm = BuildVm(new FakeWorker());
var browsed = false;
vm.BrowseForSessionRequested = () => browsed = true;
await vm.RefreshSessionTargetsAsync();
// Browse first (survives a failing list query), then the lists in sort order.
Assert.Equal(3, vm.SessionTargets.Count);
Assert.Equal(new[] { "Beta", "Alpha" }, vm.SessionTargets.Skip(1).Select(t => t.Label).ToArray());
vm.SessionTargets[0].StartCommand.Execute(null);
Assert.True(browsed);
}
private async Task SeedQueueAsync()
{
await using var db = NewContext();
@@ -66,6 +66,7 @@ file sealed class NoActiveMergeState : IActiveMergeState
{
public static readonly NoActiveMergeState Instance = new();
public bool HasActiveMerge(string taskId) => false;
public Task<bool> HasActiveMergeAsync(string taskId, CancellationToken ct) => Task.FromResult(false);
}
// Never actually invoked: TicketSystemConfig.IsConfigured is false in every TaskStateServiceBuilder
@@ -162,6 +162,36 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable
Assert.Contains(spy, c => c.Method == "PlanningCompleted");
}
[Fact]
public async Task CancelAsync_UnitMergeParentMidMergeAfterSimulatedRestart_BlocksUntilAborted()
{
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var (parentId, _, _, _) = await SeedPlanningThreeChildrenMiddleConflictsAsync(db, repo);
var (orch, _) = BuildOrchestrator(db);
var startResult = await orch.StartAsync(parentId, "main", CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, startResult.Status);
// Simulate a worker restart: a brand-new orchestrator instance has an empty in-memory
// _states dictionary, so the guard must fall back to the on-disk mid-merge check.
var (freshOrch, _) = BuildOrchestrator(db);
var cancelBuilt = TaskStateServiceBuilder.Build(db.CreateFactory(), () => freshOrch);
var blocked = await cancelBuilt.State.CancelAsync(parentId, DateTime.UtcNow, CancellationToken.None);
Assert.False(blocked.Ok);
using (var ctx = db.CreateContext())
Assert.Equal(TaskStatus.WaitingForReview, ctx.Tasks.Single(t => t.Id == parentId).Status);
GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
var ok = await cancelBuilt.State.CancelAsync(parentId, DateTime.UtcNow, CancellationToken.None);
Assert.True(ok.Ok);
}
private async Task<(string parentId, string subA, string subB, string subC)> SeedPlanningThreeChildrenMiddleConflictsAsync(
DbFixture db, GitRepoFixture repo)
{
@@ -1536,6 +1536,91 @@ public class TaskMergeServiceTests : IDisposable
Assert.Contains(proxy.Calls, c => c.Method == "WorktreeUpdated" && c.Args[0] is string s && s == task.Id);
}
[Fact]
public async Task ApproveAndMergeAsync_AfterRevert_BranchGone_ReturnsBlockedInsteadOfFakeMerge()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, _) = BuildService(db);
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
// Approve removes the worktree AND deletes its branch (removeWorktree: true), so after a
// later revert there is genuinely nothing left to re-merge.
var approveResult = await svc.ApproveAndMergeAsync(task.Id, currentBranch, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, approveResult.Status);
var revertResult = await svc.RevertMergeAsync(task.Id, currentBranch, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusReverted, revertResult.Status);
var reapproveResult = await svc.ApproveAndMergeAsync(task.Id, currentBranch, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusBlocked, reapproveResult.Status);
Assert.Contains("branch", reapproveResult.ErrorMessage ?? "", StringComparison.OrdinalIgnoreCase);
Assert.Contains("no longer exists", reapproveResult.ErrorMessage ?? "");
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Kept, wt!.State);
}
[Fact]
public async Task ApproveAndMergeAsync_AfterRevert_BranchStillExists_ReallyMergesAgain()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, _) = BuildService(db);
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
// Merge (not approve) with removeWorktree: false, so the branch and worktree dir survive
// the merge -- e.g. a manual merge_task call, or a merge whose cleanup was skipped.
var mergeResult = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
commitMessage: "Merge task", ct: CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, mergeResult.Status);
string? firstMergeCommit;
using (var preRevertCtx = db.CreateContext())
firstMergeCommit = (await new WorktreeRepository(preRevertCtx).GetByTaskIdAsync(task.Id))!.MergeCommit;
var revertResult = await svc.RevertMergeAsync(task.Id, currentBranch, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusReverted, revertResult.Status);
Assert.False(File.Exists(Path.Combine(repo.RepoDir, "added.txt")));
var reapproveResult = await svc.ApproveAndMergeAsync(task.Id, currentBranch, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, reapproveResult.Status);
Assert.True(File.Exists(Path.Combine(repo.RepoDir, "added.txt")));
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, updated!.Status);
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Merged, wt!.State);
Assert.False(string.IsNullOrWhiteSpace(wt.MergeCommit));
Assert.NotEqual(firstMergeCommit, wt.MergeCommit);
}
[Fact]
public async Task RevertMergeAsync_UncommittedChangesInTarget_ReturnsBlocked()
{
@@ -15,6 +15,7 @@ file sealed class FakeActiveMergeState : IActiveMergeState
{
public HashSet<string> ActiveTaskIds { get; } = new();
public bool HasActiveMerge(string taskId) => ActiveTaskIds.Contains(taskId);
public Task<bool> HasActiveMergeAsync(string taskId, CancellationToken ct) => Task.FromResult(HasActiveMerge(taskId));
}
public sealed class TaskStateServiceTests : IDisposable