Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
251b70ce83 | ||
|
|
8e2d37e63e | ||
|
|
edcaee77dc | ||
|
|
bfb1c6523c | ||
|
|
1770b67713 | ||
|
|
3d09323ee1 | ||
|
|
7c399b26ba | ||
|
|
d6696c3317 | ||
|
|
73e038edd9 | ||
|
|
9d22c775f2 | ||
|
|
407bc30554 | ||
|
|
5ecf6f750a | ||
|
|
eb62371430 | ||
|
|
395874d834 | ||
|
|
29ea18c427 | ||
|
|
1bdf20272d | ||
|
|
7eba0affbe | ||
|
|
d2648f965c | ||
|
|
897f287ee1 | ||
|
|
1a5728749b | ||
|
|
e628d97d29 | ||
|
|
0cec0a77ee | ||
|
|
acff815706 | ||
|
|
8e6f0d1605 | ||
|
|
bd393a0599 | ||
|
|
b4b6225432 | ||
|
|
46a0a27620 | ||
|
|
423d39e9fb | ||
|
|
951c2fe92a | ||
|
|
6f1fbe5b93 | ||
|
|
cfda3e9044 | ||
|
|
2a34d2453f | ||
|
|
451c01848c | ||
|
|
0235b43bad | ||
|
|
3348736e9d | ||
|
|
752cf0926b | ||
|
|
4cf601e423 | ||
|
|
c1b133ff90 | ||
|
|
256bea5ffa | ||
|
|
2b0562b2ed | ||
|
|
741f66fe14 | ||
|
|
2d67ca3171 | ||
|
|
0fcfe7c524 | ||
|
|
1c76852957 | ||
|
|
c9d4823f06 | ||
|
|
dbee4a0de9 | ||
|
|
1177463a82 | ||
|
|
5477ae5302 | ||
|
|
a0a85c5c24 | ||
|
|
25c9a619dc | ||
|
|
13e860f06b | ||
|
|
ec1e60be1d |
@@ -1,5 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
## v2.14.0 — 2026-09-10
|
||||
|
||||
### Features
|
||||
|
||||
- Listen und Ordner in einer gemischten Sidebar-Reihenfolge (bfb1c652)
|
||||
- confirm before closing a running ConPTY session (eb623714)
|
||||
- worker-menu Shutdown ends worker + app after confirm (1bdf2027)
|
||||
- chain a normal parent's children on WaitingForChildren (d2648f96)
|
||||
- localize WorkConsole, DescriptionStepsCard and the ROADBLOCK label (8e6f0d16)
|
||||
- surface depends_on blocking in status chip, chain label, and detail pane (4cf601e4)
|
||||
- Worker-Menü "Update Claude CLI" führt claude update aus (dbee4a0d)
|
||||
- triage model picker (opus/fable) in the merge helper start modal (1177463a)
|
||||
- collapsible folders for the list sidebar (25c9a619)
|
||||
- list_folders table plus lists.folder_id (13e860f0)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Import broadcastet TaskUpdated fuer neue Tasks (1770b677)
|
||||
- ConnectedClientTracker in den externen MCP-Container teilen (3d09323e)
|
||||
- de.json-Konsistenz-Pass und restliche harte EN-Strings (d6696c33)
|
||||
- force English git output for locale-fragile stderr matches (5ecf6f75)
|
||||
- make diff-stat parsing locale-safe and dedupe the two parsers (29ea18c4)
|
||||
- report failed paste/drop in the interactive terminal (0cec0a77)
|
||||
- surface worker connect/restart failures instead of no-opting (b4b62254)
|
||||
- validate PowerUse window, explain effort and Prime Claude in Settings (951c2fe9)
|
||||
- start_list_handler ehrlich machen, wenn keine App verbunden ist (451c0184)
|
||||
- warn and block save when a list's working dir isn't a git repo (3348736e)
|
||||
- show a banner when a ConPTY session dies after startup (256bea5f)
|
||||
- merge duplicate top-level "checks" block in en/de.json (741f66fe)
|
||||
- translate max_turns failure text to English (0fcfe7c5)
|
||||
- route WeekReportService permission-mode through PermissionModeResolver (1c768529)
|
||||
- report startup port conflicts instead of crashing silently (c9d4823f)
|
||||
|
||||
### Documentation
|
||||
|
||||
- record list folders in the Data and Ui CLAUDE.md (5477ae53)
|
||||
- update for v2.13.6 (ec1e60be)
|
||||
|
||||
## v2.13.6 — 2026-09-07
|
||||
|
||||
### Features
|
||||
|
||||
- Home-Ordner als Sitzungsziel in Mission Control (f231f08a)
|
||||
- Einfuegen und Datei-Drop im interaktiven Terminal (23491d3b)
|
||||
- Cheat-Sheet-Fenster im Hilfe-Menue (0db9fade)
|
||||
- PowerUse-Einstellungen nach Settings -> Ausfuehrung verschieben (7c7c33d6)
|
||||
|
||||
### Documentation
|
||||
|
||||
- update for v2.13.5 (c7392e08)
|
||||
|
||||
## v2.13.5 — 2026-09-03
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -98,6 +98,7 @@ Daily prep: `GetDailyPrepCandidates`, `SetMyDay`.
|
||||
| `TaskWaitMcpTools` | `WaitForTaskChange` |
|
||||
| `QueueStateMcpTools` | `GetQueueState` |
|
||||
| `AttachmentMcpTools` | `AddTaskAttachment`, `ListTaskAttachments`, `RemoveTaskAttachment` |
|
||||
| `ListHandlerMcpTools` | `StartListHandler` |
|
||||
|
||||
## Per-tool behaviour worth knowing
|
||||
|
||||
@@ -235,6 +236,13 @@ task already outside those two (unknown ids reported as status `"NotFound"`, als
|
||||
**`AttachmentMcpTools`** — re-attaching the same `fileName` overwrites. Add/remove refuse on a
|
||||
`Running` task.
|
||||
|
||||
**`StartListHandler`** — the worker only broadcasts `ListHandlerStartRequested`; the connected
|
||||
ClaudeDo App does the actual work (creates the handler task, opens the triage ConPTY tile). Before
|
||||
broadcasting it checks `ConnectedClientTracker` (a `WorkerHub.OnConnectedAsync`/`OnDisconnectedAsync`-driven
|
||||
singleton count) — with zero connected clients the broadcast would reach nobody, so the tool
|
||||
returns `Requested: false` plus a `Reason` instead of claiming success. Does not queue or retry the
|
||||
request for a later connect.
|
||||
|
||||
**`GetDailyPrepCandidates`** — Idle, non-blocked tasks in a git repo **not** excluded by
|
||||
`AppSettings.ReportExcludedPaths` and not already `IsMyDay`, plus the current Idle MyDay tasks
|
||||
and `maxTasks` (= `DailyPrepMaxTasks`). Repo-exclusion logic lives in the `DailyPrepFilter`
|
||||
|
||||
@@ -12,7 +12,9 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
||||
- `InteractiveSessionId` = the claude session id an embedded ConPTY interactive task session runs under, persisted by `InteractiveLaunchSpecService` before launch so a closed/aborted session can be resumed → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md).
|
||||
- `Number` (INTEGER NOT NULL, unique index `idx_tasks_number`) — a global, monotonically increasing display alias for tasks. **Never reused**: deleting a task leaves a gap in numbering, ensuring that a task number that appears in a log or report always points to the same task (if it exists). Allocated by `TaskNumberAllocator.AddWithNumberAsync` on every insert via a persistent counter (`app_settings.next_task_number`). **Why not `MAX(number) + 1`:** deleting the highest-numbered task would free its number for reuse, breaking the immutability guarantee. The allocator uses an `UPDATE…RETURNING` statement to claim numbers atomically, retrying up to 5 times if a uniqueness collision occurs (the counter advances on each attempt, so retries always allocate fresh numbers). The one-time backfill for pre-existing rows lived in the `AddTaskNumbers` migration, squashed away 2026-08-26.
|
||||
- Legacy status values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired. The backfill migration was squashed away (2026-08-26) — a database still holding those values can't be upgraded, see Schema below.
|
||||
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`)
|
||||
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`), Icon, FolderId
|
||||
- `FolderId` (`folder_id`, nullable FK → `list_folders.id`, `ON DELETE SET NULL`) — the sidebar folder the list sits in; null = top level. Written only by the UI (`ListsIslandViewModel` drag/context menu), never by MCP.
|
||||
- **ListFolderEntity** — Id, Name, Icon, SortOrder, IsCollapsed, CreatedAt → `list_folders`. A pure sidebar grouping: no tasks, no config, no working dir, **one level** (folders never nest). Deliberately its own table rather than a flag on `lists`, so every caller that enumerates lists (MCP `list_task_lists`, "move to list", the queue picker, repo import) needs no folder filtering. Folder CRUD lives on `ListRepository`.
|
||||
- **ListConfigEntity** — ListId (PK, 1:1), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand, PermissionMode, TicketProjectId (all nullable). `VerifyCommand` is an optional post-merge gate; null/blank = no gate → [review-merge](../../docs/explore-notes/review-merge.md). `PermissionMode` is null = inherit the global default; resolution order task → list → global lives in `EffectiveRunConfigResolver`. `TicketProjectId` (`ticket_project_id`) links the list to a project in the external ticket system; null = not linked → `ClaudeDo.Worker/CLAUDE.md` Tickets section.
|
||||
- ⚠️ `ListRepository.SetConfigAsync` copies the entity **verbatim**, so every writer must carry the fields it doesn't own (`SessionSkills`, `PermissionMode`, `TicketProjectId`) or they silently reset. `UpdateListConfigDto.TicketProjectId` is tri-state (`null` = keep stored) for exactly that reason — only the list-settings modal sends an explicit value. This bit `PermissionMode` for real: its update-branch copy was missing from `SetConfigAsync` until `f0a3a186`, so a list's permission-mode override only ever stuck on the list's *first* save, never on a later one.
|
||||
- **WorktreeEntity** — TaskId (PK, 1:1), Path, BranchName, BaseCommit, HeadCommit, DiffStat, MergeCommit (nullable — SHA of the merge commit this branch produced; the only thing making `revert_merge` possible without searching `git log`), State (`Active|Merged|Discarded|Kept`)
|
||||
@@ -65,7 +67,8 @@ All use EF Core LINQ via `ClaudeDoDbContext`. The atomic `Queued → Running` cl
|
||||
Worker's `QueuePicker` (`FromSqlRaw`), **not** here.
|
||||
|
||||
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync`. ⚠️ Status-mutation primitives (`MarkRunningAsync`/`MarkDoneAsync`/`MarkFailedAsync`/`FlipAllRunningToFailedAsync`) are **`internal`** — only the Worker's `TaskStateService` may call them. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`. **Task-number allocation:** `AddAsync` (line 20) and `CreateChildAsync` (line 276) are the two sole task-creation paths; both call `TaskNumberAllocator.AddWithNumberAsync` to allocate a number atomically during the same transaction as the insert. Every other creation path (UI, MCP `add_task`, `batch_add_tasks`, Online Inbox sync, merge-helper handler tasks, planning children) routes through one of these two.
|
||||
- **ListRepository** — CRUD, `GetConfigAsync`/`SetConfigAsync` (upsert)/`DeleteConfigAsync` for `list_config`
|
||||
- **ListRepository** — CRUD, `GetConfigAsync`/`SetConfigAsync` (upsert)/`DeleteConfigAsync` for `list_config`, plus the `list_folders` set: `GetFoldersAsync`, `AddFolderAsync` (appends), `RenameFolderAsync`, `DeleteFolderAsync` (folder only — the FK releases its lists), `SetFolderCollapsedAsync`, `SetListFolderAsync`. The folder writers are `ExecuteUpdate` patches, not entity `Update()`s, so a rename can't reset `SortOrder`/`IsCollapsed` the way `SetConfigAsync`'s verbatim copy resets forgotten columns.
|
||||
- ⚠️ **`lists.sort_order` and `list_folders.sort_order` share one number space** — the sidebar interleaves folders and top-level lists in a single order, so `ReorderSidebarAsync` numbers the whole visual sequence (each folder followed by its children) across both tables, and `AddAsync`/`AddFolderAsync` append via `MAX(sort_order)+1` over **both**. Never renumber one table on its own.
|
||||
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`, `SetMergedAsync` (atomically sets State=Merged **and** stamps MergeCommit in one update — the only writer of MergeCommit)
|
||||
- **TaskAttachmentRepository** — `AddAsync`, `UpdateAsync`, `GetAsync(taskId, fileName)`, `ListByTaskIdAsync`, `DeleteAsync(taskId, fileName)`. Bulk delete-per-task is the `ON DELETE CASCADE` on `task_attachments`, not a repository method.
|
||||
- **DailyNoteRepository**, **WeekReportRepository**, **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
|
||||
@@ -116,6 +119,7 @@ Tables (one per line so parallel migrations don't collide on the same line):
|
||||
- `week_reports`
|
||||
- `task_attachments`
|
||||
- `task_dependencies`
|
||||
- `list_folders`
|
||||
|
||||
Managed by EF Core migrations in `Migrations/`. **The 40 incremental migrations were squashed into
|
||||
a single `20260826094154_InitialCreate` on 2026-08-26** — the schema history is git history now,
|
||||
|
||||
@@ -43,6 +43,7 @@ public class ClaudeDoDbContext : DbContext
|
||||
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
|
||||
public DbSet<TaskDependencyEntity> TaskDependencies => Set<TaskDependencyEntity>();
|
||||
public DbSet<ListEntity> Lists => Set<ListEntity>();
|
||||
public DbSet<ListFolderEntity> ListFolders => Set<ListFolderEntity>();
|
||||
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
|
||||
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
|
||||
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
|
||||
|
||||
@@ -20,8 +20,17 @@ public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
|
||||
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.Property(l => l.FolderId).HasColumnName("folder_id");
|
||||
|
||||
builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort");
|
||||
builder.HasIndex(l => l.FolderId).HasDatabaseName("idx_lists_folder");
|
||||
|
||||
// No navigation property on either side: nothing needs the graph, and a nav would have to
|
||||
// be Include'd (or silently come back empty) in every list query.
|
||||
builder.HasOne<ListFolderEntity>()
|
||||
.WithMany()
|
||||
.HasForeignKey(l => l.FolderId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
builder.HasOne(l => l.Config)
|
||||
.WithOne(c => c.List)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ClaudeDo.Data.Configuration;
|
||||
|
||||
public class ListFolderEntityConfiguration : IEntityTypeConfiguration<ListFolderEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ListFolderEntity> builder)
|
||||
{
|
||||
builder.ToTable("list_folders");
|
||||
|
||||
builder.HasKey(f => f.Id);
|
||||
builder.Property(f => f.Id).HasColumnName("id");
|
||||
builder.Property(f => f.Name).HasColumnName("name").IsRequired();
|
||||
builder.Property(f => f.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||
builder.Property(f => f.Icon).HasColumnName("icon");
|
||||
builder.Property(f => f.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
||||
builder.Property(f => f.IsCollapsed).HasColumnName("is_collapsed").IsRequired().HasDefaultValue(false);
|
||||
|
||||
builder.HasIndex(f => f.SortOrder).HasDatabaseName("idx_list_folders_sort");
|
||||
}
|
||||
}
|
||||
@@ -424,14 +424,22 @@ public sealed class GitService
|
||||
return File.Exists(Path.Combine(gitDir, "MERGE_HEAD"));
|
||||
}
|
||||
|
||||
/// <param name="autostash">Stash the target working tree's uncommitted changes before the
|
||||
/// merge and restore them after it. Git owns the bookkeeping: on a conflict the stash stays
|
||||
/// pending and is re-applied by `merge --continue`/`--abort`, so a conflicted merge left in
|
||||
/// the tree doesn't lose it. A restore that itself conflicts leaves the stash in
|
||||
/// `git stash list` rather than dropping it.</param>
|
||||
public async Task<(int ExitCode, string Stderr)> MergeNoFfAsync(
|
||||
string repoDir, string sourceBranch, string message, CancellationToken ct = default)
|
||||
string repoDir, string sourceBranch, string message, CancellationToken ct = default,
|
||||
bool autostash = false)
|
||||
{
|
||||
// diff3 conflict style writes the merge base (|||||||) into conflict markers so the
|
||||
// in-app resolver can show a true three-way view. It only enriches conflicted hunks;
|
||||
// clean merges are unaffected.
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
||||
["-c", "merge.conflictStyle=diff3", "merge", "--no-ff", "-m", message, sourceBranch], ct);
|
||||
string[] args = autostash
|
||||
? ["-c", "merge.conflictStyle=diff3", "merge", "--no-ff", "--autostash", "-m", message, sourceBranch]
|
||||
: ["-c", "merge.conflictStyle=diff3", "merge", "--no-ff", "-m", message, sourceBranch];
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir, args, ct);
|
||||
return (exitCode, stderr);
|
||||
}
|
||||
|
||||
@@ -704,6 +712,11 @@ public sealed class GitService
|
||||
// (mktree "input format error") and would prepend to stdin commit messages.
|
||||
StandardInputEncoding = stdinData is not null ? new UTF8Encoding(false) : null,
|
||||
};
|
||||
// Force English stderr/stdout so the English-only substring matches below
|
||||
// (e.g. "ambiguous argument 'HEAD'", "tell me who you are") work regardless
|
||||
// of the OS locale git for Windows picks up.
|
||||
psi.Environment["LC_ALL"] = "C";
|
||||
psi.Environment["LANGUAGE"] = "C";
|
||||
psi.ArgumentList.Add("-C");
|
||||
psi.ArgumentList.Add(workDir);
|
||||
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddListFolders : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "folder_id",
|
||||
table: "lists",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "list_folders",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
icon = table.Column<string>(type: "TEXT", nullable: true),
|
||||
sort_order = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 0),
|
||||
is_collapsed = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_list_folders", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_lists_folder",
|
||||
table: "lists",
|
||||
column: "folder_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_list_folders_sort",
|
||||
table: "list_folders",
|
||||
column: "sort_order");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_lists_list_folders_folder_id",
|
||||
table: "lists",
|
||||
column: "folder_id",
|
||||
principalTable: "list_folders",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_lists_list_folders_folder_id",
|
||||
table: "lists");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "list_folders");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "idx_lists_folder",
|
||||
table: "lists");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "folder_id",
|
||||
table: "lists");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,6 +324,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("findings_tracked");
|
||||
|
||||
b.Property<string>("FolderId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("folder_id");
|
||||
|
||||
b.Property<string>("Icon")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("icon");
|
||||
@@ -351,12 +355,54 @@ namespace ClaudeDo.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FolderId")
|
||||
.HasDatabaseName("idx_lists_folder");
|
||||
|
||||
b.HasIndex("SortOrder")
|
||||
.HasDatabaseName("idx_lists_sort");
|
||||
|
||||
b.ToTable("lists", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListFolderEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("Icon")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("icon");
|
||||
|
||||
b.Property<bool>("IsCollapsed")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_collapsed");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SortOrder")
|
||||
.HasDatabaseName("idx_list_folders_sort");
|
||||
|
||||
b.ToTable("list_folders", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -934,6 +980,14 @@ namespace ClaudeDo.Data.Migrations
|
||||
b.Navigation("List");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListFolderEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("FolderId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
|
||||
@@ -16,6 +16,9 @@ public sealed class ListEntity
|
||||
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; }
|
||||
// Sidebar folder this list sits in (ListFolderEntity.Id); null = top level. ON DELETE SET NULL,
|
||||
// so deleting a folder moves its lists back to the top level instead of deleting them.
|
||||
public string? FolderId { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
public ListConfigEntity? Config { get; set; }
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ClaudeDo.Data.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A sidebar folder grouping user lists. Deliberately its own table rather than a flag on
|
||||
/// <see cref="ListEntity"/>: every place that enumerates lists (MCP list_task_lists, "move to
|
||||
/// list", the queue picker, repo import) would otherwise have to filter folders out.
|
||||
/// One level only — a folder holds lists, never other folders.
|
||||
/// </summary>
|
||||
public sealed class ListFolderEntity
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Name { get; set; }
|
||||
public required DateTime CreatedAt { get; init; }
|
||||
// Icon key into the UI's icon library, same set as ListEntity.Icon; null = default folder icon.
|
||||
public string? Icon { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
// Sidebar expand/collapse state. Lives here rather than in ui.config.json because the row is
|
||||
// already being read for the folder itself.
|
||||
public bool IsCollapsed { get; set; }
|
||||
}
|
||||
@@ -17,10 +17,20 @@ public sealed class ListRepository
|
||||
public async Task AddAsync(ListEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
NormalizeWorkingDir(entity);
|
||||
if (entity.SortOrder == 0) entity.SortOrder = await NextSortOrderAsync(ct);
|
||||
_context.Lists.Add(entity);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>One past the highest <c>sort_order</c> in use — across <b>both</b> tables, because
|
||||
/// the sidebar interleaves folders and top-level lists in a single order sequence.</summary>
|
||||
private async Task<int> NextSortOrderAsync(CancellationToken ct = default)
|
||||
{
|
||||
var maxList = await _context.Lists.MaxAsync(l => (int?)l.SortOrder, ct) ?? -1;
|
||||
var maxFolder = await _context.ListFolders.MaxAsync(f => (int?)f.SortOrder, ct) ?? -1;
|
||||
return Math.Max(maxList, maxFolder) + 1;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
NormalizeWorkingDir(entity);
|
||||
@@ -56,18 +66,83 @@ public sealed class ListRepository
|
||||
return await _context.Lists.OrderBy(l => l.SortOrder).ThenBy(l => l.CreatedAt).ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task ReorderAsync(IReadOnlyList<string> orderedListIds, CancellationToken ct = default)
|
||||
/// <summary>
|
||||
/// Writes the sidebar's visual order as one sequence spanning both tables: folders and lists
|
||||
/// share the <c>sort_order</c> number space, which is what lets a top-level list sit between
|
||||
/// two folders. A folder's own children follow it in the sequence, so ordering folders by
|
||||
/// <c>sort_order</c> and lists by <c>sort_order</c> independently still reproduces the sidebar.
|
||||
/// </summary>
|
||||
public async Task ReorderSidebarAsync(IReadOnlyList<(string Id, bool IsFolder)> ordered, CancellationToken ct = default)
|
||||
{
|
||||
var idSet = orderedListIds.ToHashSet();
|
||||
var entities = await _context.Lists.Where(l => idSet.Contains(l.Id)).ToListAsync(ct);
|
||||
for (int i = 0; i < orderedListIds.Count; i++)
|
||||
var listIds = ordered.Where(o => !o.IsFolder).Select(o => o.Id).ToHashSet();
|
||||
var folderIds = ordered.Where(o => o.IsFolder).Select(o => o.Id).ToHashSet();
|
||||
var lists = await _context.Lists.Where(l => listIds.Contains(l.Id)).ToListAsync(ct);
|
||||
var folders = await _context.ListFolders.Where(f => folderIds.Contains(f.Id)).ToListAsync(ct);
|
||||
|
||||
for (int i = 0; i < ordered.Count; i++)
|
||||
{
|
||||
var e = entities.FirstOrDefault(x => x.Id == orderedListIds[i]);
|
||||
if (e is not null) e.SortOrder = i;
|
||||
var (id, isFolder) = ordered[i];
|
||||
if (isFolder)
|
||||
{
|
||||
if (folders.FirstOrDefault(f => f.Id == id) is { } folder) folder.SortOrder = i;
|
||||
}
|
||||
else if (lists.FirstOrDefault(l => l.Id == id) is { } list) list.SortOrder = i;
|
||||
}
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// ---- Sidebar folders (list_folders) ----
|
||||
// Folders live here rather than in their own repository because they exist only as a grouping
|
||||
// of these lists — every caller that touches one already holds a ListRepository.
|
||||
|
||||
public async Task<List<ListFolderEntity>> GetFoldersAsync(CancellationToken ct = default)
|
||||
{
|
||||
return await _context.ListFolders
|
||||
.OrderBy(f => f.SortOrder).ThenBy(f => f.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task AddFolderAsync(ListFolderEntity folder, CancellationToken ct = default)
|
||||
{
|
||||
folder.SortOrder = await NextSortOrderAsync(ct);
|
||||
_context.ListFolders.Add(folder);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Patches just the two editable fields — a whole-entity Update would need
|
||||
/// <c>CreatedAt</c>/<c>SortOrder</c>/<c>IsCollapsed</c> carried along and silently reset
|
||||
/// whichever the caller forgot (see the SetConfigAsync note in CLAUDE.md).</summary>
|
||||
public async Task RenameFolderAsync(string folderId, string name, string? icon, CancellationToken ct = default)
|
||||
{
|
||||
await _context.ListFolders
|
||||
.Where(f => f.Id == folderId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(f => f.Name, name)
|
||||
.SetProperty(f => f.Icon, icon), ct);
|
||||
}
|
||||
|
||||
/// <summary>Deletes the folder only — its lists survive and fall back to the top level via
|
||||
/// the ON DELETE SET NULL on <c>lists.folder_id</c>.</summary>
|
||||
public async Task DeleteFolderAsync(string folderId, CancellationToken ct = default)
|
||||
{
|
||||
await _context.ListFolders.Where(f => f.Id == folderId).ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
public async Task SetFolderCollapsedAsync(string folderId, bool collapsed, CancellationToken ct = default)
|
||||
{
|
||||
await _context.ListFolders
|
||||
.Where(f => f.Id == folderId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(f => f.IsCollapsed, collapsed), ct);
|
||||
}
|
||||
|
||||
/// <summary>Moves a list into <paramref name="folderId"/>, or back to the top level when null.</summary>
|
||||
public async Task SetListFolderAsync(string listId, string? folderId, CancellationToken ct = default)
|
||||
{
|
||||
await _context.Lists
|
||||
.Where(l => l.Id == listId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(l => l.FolderId, folderId), ct);
|
||||
}
|
||||
|
||||
public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == listId, ct);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Localization;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
@@ -23,11 +24,12 @@ public sealed class ClaudeAuthCheck : IEnvironmentCheck
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var loc = TrExtension.Localizer;
|
||||
var lookup = await _lookup.ResolveAsync(ctx, ct);
|
||||
if (lookup.Resolved is null)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title",
|
||||
"The claude CLI was not found — login could not be checked.");
|
||||
loc?["checks.claudeAuth.notFound"] ?? "The claude CLI was not found — login could not be checked.");
|
||||
}
|
||||
|
||||
var (fileName, arguments) = ProcessCommand.For(lookup.Resolved, "auth", "status", "--json");
|
||||
@@ -36,19 +38,19 @@ public sealed class ClaudeAuthCheck : IEnvironmentCheck
|
||||
if (exitCode != 0)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title",
|
||||
"Login status could not be determined.", output);
|
||||
loc?["checks.claudeAuth.undetermined"] ?? "Login status could not be determined.", output);
|
||||
}
|
||||
|
||||
var loggedIn = TryParseLoggedIn(output);
|
||||
if (loggedIn is null)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title",
|
||||
"Login status could not be determined.", output);
|
||||
loc?["checks.claudeAuth.undetermined"] ?? "Login status could not be determined.", output);
|
||||
}
|
||||
|
||||
return loggedIn.Value
|
||||
? CheckResult.Ok(Id, Severity, "checks.claudeAuth.title", "Logged in.")
|
||||
: CheckResult.Fail(Id, Severity, "checks.claudeAuth.title", "Not logged in.", "checks.claudeAuth.hint");
|
||||
? CheckResult.Ok(Id, Severity, "checks.claudeAuth.title", loc?["checks.claudeAuth.loggedIn"] ?? "Logged in.")
|
||||
: CheckResult.Fail(Id, Severity, "checks.claudeAuth.title", loc?["checks.claudeAuth.notLoggedIn"] ?? "Not logged in.", "checks.claudeAuth.hint");
|
||||
}
|
||||
|
||||
private static bool? TryParseLoggedIn(string output)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.IO;
|
||||
using ClaudeDo.Data.Environment;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Localization;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
@@ -21,20 +22,21 @@ public sealed class ClaudeCliCheck : IEnvironmentCheck
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var loc = TrExtension.Localizer;
|
||||
var lookup = await _lookup.ResolveAsync(ctx, ct);
|
||||
|
||||
if (lookup.Resolved is null)
|
||||
{
|
||||
var searched = string.Join(", ", SearchedLocations());
|
||||
return CheckResult.Fail(Id, Severity, "checks.claudeCli.title",
|
||||
$"'{ctx.ClaudeBin}' was not found on PATH. Searched: {searched}.",
|
||||
loc?.Get("checks.claudeCli.notFound", ctx.ClaudeBin, searched) ?? $"'{ctx.ClaudeBin}' was not found on PATH. Searched: {searched}.",
|
||||
"checks.claudeCli.hint");
|
||||
}
|
||||
|
||||
if (lookup.ExitCode != 0)
|
||||
{
|
||||
return CheckResult.Fail(Id, Severity, "checks.claudeCli.title",
|
||||
$"'{lookup.Resolved.Path}' exited with code {lookup.ExitCode}.",
|
||||
loc?.Get("checks.claudeCli.exitCode", lookup.Resolved.Path, lookup.ExitCode) ?? $"'{lookup.Resolved.Path}' exited with code {lookup.ExitCode}.",
|
||||
"checks.claudeCli.hint", detail: lookup.Output);
|
||||
}
|
||||
|
||||
@@ -42,7 +44,8 @@ public sealed class ClaudeCliCheck : IEnvironmentCheck
|
||||
var detail = lookup.Resolved.IsShim
|
||||
? "Resolved via a shim (.cmd/.bat/.ps1), not a native .exe."
|
||||
: null;
|
||||
return CheckResult.Ok(Id, Severity, "checks.claudeCli.title", $"{lookup.Resolved.Path} — {version}", detail);
|
||||
return CheckResult.Ok(Id, Severity, "checks.claudeCli.title",
|
||||
loc?.Get("checks.claudeCli.resolvedFormat", lookup.Resolved.Path, version) ?? $"{lookup.Resolved.Path} — {version}", detail);
|
||||
}
|
||||
|
||||
private IEnumerable<string> SearchedLocations()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Localization;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
@@ -27,28 +28,29 @@ public sealed class ClaudeVersionCheck : IEnvironmentCheck
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var loc = TrExtension.Localizer;
|
||||
var lookup = await _lookup.ResolveAsync(ctx, ct);
|
||||
|
||||
if (lookup.Resolved is null)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.claudeVersion.title",
|
||||
"The claude CLI was not found — version could not be checked.");
|
||||
loc?["checks.claudeVersion.notFound"] ?? "The claude CLI was not found — version could not be checked.");
|
||||
}
|
||||
|
||||
if (lookup.ExitCode != 0 || lookup.ParsedVersion is null)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.claudeVersion.title",
|
||||
$"Could not parse a version from '{lookup.Output.Trim()}'.");
|
||||
loc?.Get("checks.claudeVersion.parseFailed", lookup.Output.Trim()) ?? $"Could not parse a version from '{lookup.Output.Trim()}'.");
|
||||
}
|
||||
|
||||
if (lookup.ParsedVersion < MinimumVersion)
|
||||
{
|
||||
return CheckResult.Fail(Id, Severity, "checks.claudeVersion.title",
|
||||
$"claude {lookup.ParsedVersion} is older than the required {MinimumVersion}.",
|
||||
loc?.Get("checks.claudeVersion.tooOld", lookup.ParsedVersion, MinimumVersion) ?? $"claude {lookup.ParsedVersion} is older than the required {MinimumVersion}.",
|
||||
"checks.claudeVersion.hint");
|
||||
}
|
||||
|
||||
return CheckResult.Ok(Id, Severity, "checks.claudeVersion.title",
|
||||
$"claude {lookup.ParsedVersion} meets the minimum ({MinimumVersion}).");
|
||||
loc?.Get("checks.claudeVersion.ok", lookup.ParsedVersion, MinimumVersion) ?? $"claude {lookup.ParsedVersion} meets the minimum ({MinimumVersion}).");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.RegularExpressions;
|
||||
using ClaudeDo.Data.Environment;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Core.Interfaces;
|
||||
using ClaudeDo.Installer.Localization;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
@@ -27,10 +28,12 @@ public sealed class GitCheck : IEnvironmentCheck
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var loc = TrExtension.Localizer;
|
||||
var resolved = ExecutableResolver.Resolve("git", _pathOverride, _pathExtOverride);
|
||||
if (resolved is null)
|
||||
{
|
||||
return CheckResult.Fail(Id, Severity, "checks.git.title", "git was not found on PATH.",
|
||||
return CheckResult.Fail(Id, Severity, "checks.git.title",
|
||||
loc?["checks.git.notFound"] ?? "git was not found on PATH.",
|
||||
"checks.git.hint", "https://git-scm.com/downloads");
|
||||
}
|
||||
|
||||
@@ -39,11 +42,13 @@ public sealed class GitCheck : IEnvironmentCheck
|
||||
|
||||
if (exitCode != 0)
|
||||
{
|
||||
return CheckResult.Fail(Id, Severity, "checks.git.title", $"'{resolved.Path}' exited with code {exitCode}.",
|
||||
return CheckResult.Fail(Id, Severity, "checks.git.title",
|
||||
loc?.Get("checks.git.exitCode", resolved.Path, exitCode) ?? $"'{resolved.Path}' exited with code {exitCode}.",
|
||||
"checks.git.hint", "https://git-scm.com/downloads", output);
|
||||
}
|
||||
|
||||
var version = VersionPattern.Match(output) is { Success: true } match ? match.Value : output.Trim();
|
||||
return CheckResult.Ok(Id, Severity, "checks.git.title", $"{resolved.Path} — {version}");
|
||||
return CheckResult.Ok(Id, Severity, "checks.git.title",
|
||||
loc?.Get("checks.git.resolvedFormat", resolved.Path, version) ?? $"{resolved.Path} — {version}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ClaudeDo.Data.Environment;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Core.Interfaces;
|
||||
using ClaudeDo.Installer.Localization;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
@@ -25,10 +26,12 @@ public sealed class GitIdentityCheck : IEnvironmentCheck
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var loc = TrExtension.Localizer;
|
||||
var resolved = ExecutableResolver.Resolve("git", _pathOverride, _pathExtOverride);
|
||||
if (resolved is null)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.gitIdentity.title", "git was not found — identity could not be checked.");
|
||||
return CheckResult.Unknown(Id, Severity, "checks.gitIdentity.title",
|
||||
loc?["checks.gitIdentity.notFound"] ?? "git was not found — identity could not be checked.");
|
||||
}
|
||||
|
||||
var name = await ReadConfigAsync(resolved, "user.name", ct);
|
||||
@@ -48,7 +51,8 @@ public sealed class GitIdentityCheck : IEnvironmentCheck
|
||||
_ => "user.email",
|
||||
};
|
||||
|
||||
return CheckResult.Fail(Id, Severity, "checks.gitIdentity.title", $"Missing git identity: {missing}.", "checks.gitIdentity.hint");
|
||||
return CheckResult.Fail(Id, Severity, "checks.gitIdentity.title",
|
||||
loc?.Get("checks.gitIdentity.missing", missing) ?? $"Missing git identity: {missing}.", "checks.gitIdentity.hint");
|
||||
}
|
||||
|
||||
private async Task<string> ReadConfigAsync(ResolvedExecutable resolved, string key, CancellationToken ct)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Localization;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
@@ -28,11 +29,12 @@ public sealed class PermissionModeAutoCheck : IEnvironmentCheck
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var loc = TrExtension.Localizer;
|
||||
var lookup = await _lookup.ResolveAsync(ctx, ct);
|
||||
if (lookup.Resolved is null)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.permissionModeAuto.title",
|
||||
"The claude CLI was not found — permission-mode support could not be checked.");
|
||||
loc?["checks.permissionModeAuto.notFound"] ?? "The claude CLI was not found — permission-mode support could not be checked.");
|
||||
}
|
||||
|
||||
var (fileName, arguments) = ProcessCommand.For(lookup.Resolved, "--help");
|
||||
@@ -41,17 +43,17 @@ public sealed class PermissionModeAutoCheck : IEnvironmentCheck
|
||||
if (exitCode != 0 || string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.permissionModeAuto.title",
|
||||
"Could not determine whether --permission-mode auto is supported.");
|
||||
loc?["checks.permissionModeAuto.undetermined"] ?? "Could not determine whether --permission-mode auto is supported.");
|
||||
}
|
||||
|
||||
if (AutoIsListed(output))
|
||||
{
|
||||
return CheckResult.Ok(Id, Severity, "checks.permissionModeAuto.title",
|
||||
"--permission-mode auto is recognized by this CLI.");
|
||||
loc?["checks.permissionModeAuto.ok"] ?? "--permission-mode auto is recognized by this CLI.");
|
||||
}
|
||||
|
||||
return CheckResult.Fail(Id, Severity, "checks.permissionModeAuto.title",
|
||||
"This claude CLI version does not list 'auto' as a --permission-mode choice.",
|
||||
loc?["checks.permissionModeAuto.notListed"] ?? "This claude CLI version does not list 'auto' as a --permission-mode choice.",
|
||||
"checks.permissionModeAuto.hint");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using ClaudeDo.Installer.Checks.Interfaces;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Localization;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
@@ -19,6 +20,7 @@ public sealed class PortCheck : IEnvironmentCheck
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var loc = TrExtension.Localizer;
|
||||
var ports = new (string Label, int Port)[]
|
||||
{
|
||||
("SignalR", ctx.SignalRPort),
|
||||
@@ -39,7 +41,9 @@ public sealed class PortCheck : IEnvironmentCheck
|
||||
continue;
|
||||
}
|
||||
|
||||
blocked.Add(owner is null ? $"{label} port {port} is in use." : $"{label} port {port} is in use by '{owner}'.");
|
||||
blocked.Add(owner is null
|
||||
? loc?.Get("checks.ports.inUse", label, port) ?? $"{label} port {port} is in use."
|
||||
: loc?.Get("checks.ports.inUseBy", label, port, owner) ?? $"{label} port {port} is in use by '{owner}'.");
|
||||
}
|
||||
|
||||
if (blocked.Count > 0)
|
||||
@@ -49,10 +53,12 @@ public sealed class PortCheck : IEnvironmentCheck
|
||||
|
||||
if (ownWorker.Count > 0)
|
||||
{
|
||||
return CheckResult.Ok(Id, Severity, "checks.ports.title", $"In use by the running ClaudeDo Worker: {string.Join(", ", ownWorker)}.");
|
||||
var ownWorkerList = string.Join(", ", ownWorker);
|
||||
return CheckResult.Ok(Id, Severity, "checks.ports.title",
|
||||
loc?.Get("checks.ports.inUseByOwnWorker", ownWorkerList) ?? $"In use by the running ClaudeDo Worker: {ownWorkerList}.");
|
||||
}
|
||||
|
||||
return CheckResult.Ok(Id, Severity, "checks.ports.title", "Ports are available.");
|
||||
return CheckResult.Ok(Id, Severity, "checks.ports.title", loc?["checks.ports.available"] ?? "Ports are available.");
|
||||
}
|
||||
|
||||
private static bool IsFree(int port)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.IO;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Localization;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
@@ -14,17 +15,19 @@ public sealed class WriteAccessCheck : IEnvironmentCheck
|
||||
|
||||
public Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var loc = TrExtension.Localizer;
|
||||
foreach (var target in new[] { ctx.InstallDirectory, Paths.AppDataRoot() })
|
||||
{
|
||||
var error = TryWrite(target);
|
||||
if (error is not null)
|
||||
{
|
||||
return Task.FromResult(CheckResult.Fail(Id, Severity, "checks.writeAccess.title",
|
||||
$"Cannot write to '{target}': {error}", "checks.writeAccess.hint"));
|
||||
loc?.Get("checks.writeAccess.cannotWrite", target, error) ?? $"Cannot write to '{target}': {error}", "checks.writeAccess.hint"));
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(CheckResult.Ok(Id, Severity, "checks.writeAccess.title", "Install directory and data directory are writable."));
|
||||
return Task.FromResult(CheckResult.Ok(Id, Severity, "checks.writeAccess.title",
|
||||
loc?["checks.writeAccess.writable"] ?? "Install directory and data directory are writable."));
|
||||
}
|
||||
|
||||
private static string? TryWrite(string path)
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"categoryGeneral": "Allgemein",
|
||||
"categoryExecution": "Ausführung",
|
||||
"categoryPrime": "Prime Claude",
|
||||
"categoryPrimeHint": "Geplante Claude-Läufe: Ping, My Day füllen oder ein eigener Prompt, zu festen Zeiten.",
|
||||
"categoryWorktrees": "Worktrees",
|
||||
"categoryFiles": "Dateien & Prompts",
|
||||
"categorySkills": "Session Skills",
|
||||
@@ -27,6 +28,7 @@
|
||||
"model": "Modell",
|
||||
"maxTurns": "Max. Durchläufe",
|
||||
"effort": "Effort",
|
||||
"effortHint": "Denkaufwand, den das Modell anwendet. Höher kostet mehr und denkt tiefer.",
|
||||
"modelPresets": "Vorgaben pro Modell",
|
||||
"modelPresetsHint": "Effort und Durchlauf-Budget, mit denen eine Sitzung unter diesem Modell läuft. Overrides auf Listen- und Aufgabenebene gewinnen weiterhin.",
|
||||
"maxTurnsCeiling": "Max. Durchläufe – Obergrenze",
|
||||
@@ -36,11 +38,11 @@
|
||||
"maxParallelExecutions": "Max. parallele Ausführungen",
|
||||
"maxParallelExecutionsHint": "Wie viele Aufgaben aus der Warteschlange der Worker gleichzeitig ausführt.",
|
||||
"usageGateHeading": "Usage-Limit-Stopp",
|
||||
"usageGateHint": "Ab dieser Auslastung holt die Queue keine neuen Tasks mehr; laufende Tasks laufen zu Ende. 0 schaltet den Stopp ab.",
|
||||
"usageGateHint": "Ab dieser Auslastung holt die Warteschlange keine neuen Aufgaben mehr; laufende Aufgaben laufen zu Ende. 0 schaltet den Stopp ab.",
|
||||
"usageGateFiveHourPct": "5-Stunden-Fenster (%)",
|
||||
"usageGateSevenDayPct": "7-Tage-Fenster (%)",
|
||||
"autoContinueOnUsageLimit": "Nach Limit-Reset fortsetzen",
|
||||
"autoContinueOnUsageLimitHint": "Ist ein Run am Nutzungslimit abgebrochen, wird er nach Ablauf des 5-Stunden-Fensters automatisch fortgesetzt. Weckt außerdem die pausierte Queue genau zum Reset-Zeitpunkt statt erst beim nächsten Backstop-Check.",
|
||||
"autoContinueOnUsageLimitHint": "Ist ein Run am Nutzungslimit abgebrochen, wird er nach Ablauf des 5-Stunden-Fensters automatisch fortgesetzt. Weckt außerdem die pausierte Warteschlange genau zum Reset-Zeitpunkt statt erst beim nächsten Backstop-Check.",
|
||||
"listHandlerMaxHandoffs": "Handoff-Budget des List-Handlers",
|
||||
"listHandlerMaxHandoffsHint": "Wie viele Wait/Merge-Handoffs ein „Let Claude handle it\"-Run verketten darf. 4 entspricht zwei Merge-Runden. Für Läufe, die stundenlang durchlaufen sollen, höher setzen; der Wert begrenzt eine entlaufene Kette und darf daher nicht 0 sein.",
|
||||
"powerUseHeading": "PowerUse",
|
||||
@@ -50,8 +52,9 @@
|
||||
"powerUseWindowPlaceholder": "HH:mm",
|
||||
"powerUseHandoffPct": "Übergabe bei (% von 5h)",
|
||||
"powerUseWindowHint": "Beide Zeiten leer = rund um die Uhr. Das Fenster wird bei jedem Start einer Handler-Phase geprüft — eine Kette, die über das Ende hinausläuft, macht ohne PowerUse weiter.",
|
||||
"powerUseWindowInvalid": "PowerUse-Zeiten müssen im Format HH:mm sein oder leer bleiben.",
|
||||
"throttleStagesHeading": "Throttle-Stufen",
|
||||
"throttleStagesHint": "Die Parallelität wird vor dem harten Gate begrenzt: soft auf 2 Slots, hard auf 1. Eingestellt wird das durch Ziehen der Marker an den Gauges im Usage-Monitor. Der Monitor schließt diese Einstellungen — vorher speichern, wenn du etwas geändert hast.",
|
||||
"throttleStagesHint": "Die Parallelität wird vor dem harten Limit-Stopp begrenzt: soft auf 2 Slots, hard auf 1. Eingestellt wird das durch Ziehen der Marker an den Gauges im Usage-Monitor. Der Monitor schließt diese Einstellungen — vorher speichern, wenn du etwas geändert hast.",
|
||||
"throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%",
|
||||
"openUsageMonitor": "Usage-Monitor öffnen...",
|
||||
"reportExcludedPaths": "Bericht: ausgeschlossene Pfade (einer pro Zeile)",
|
||||
@@ -233,12 +236,13 @@
|
||||
"reasonIsPlanningParent": "Teil eines Plans",
|
||||
"reasonIsChildTask": "Teilaufgaben haben keine eigene Planungssitzung",
|
||||
"reasonPlanAlreadyFinalized": "Plan ist bereits finalisiert",
|
||||
"reasonNoWorktree": "Kein Worktree für diesen Task",
|
||||
"reasonNoWorktree": "Kein Worktree für diese Aufgabe",
|
||||
"cancel": "Abbrechen",
|
||||
"removeFromQueueTip": "Aus Warteschlange entfernen",
|
||||
"toggleSubtasksTip": "Unteraufgaben ein-/ausklappen",
|
||||
"agentSuggestedTip": "Vom Agenten vorgeschlagen",
|
||||
"interactiveChipTip": "Interaktive Sitzung offen — klicken, um im Mission Control dorthin zu springen",
|
||||
"waitingOnTip": "Wartet auf {0}",
|
||||
"scheduleTitle": "Aufgabe planen",
|
||||
"scheduleWhen": "WANN",
|
||||
"scheduleConfirm": "Planen",
|
||||
@@ -260,21 +264,24 @@
|
||||
"contextOpenFindings": "Findings-Ordner öffnen",
|
||||
"contextImportTickets": "Aus Ticketsystem aktualisieren",
|
||||
"contextSettings": "Einstellungen…",
|
||||
"contextFolderSettings": "Ordner-Einstellungen…",
|
||||
"contextRemoveFromFolder": "Aus Ordner herausnehmen",
|
||||
"newList": "Neue Liste",
|
||||
"addReposTip": "Repos als Listen hinzufügen",
|
||||
"noRepoBannerText": "Noch kein Repo verknüpft. ClaudeDo führt Tasks in einem Git-Repo aus.",
|
||||
"noRepoBannerText": "Noch kein Repo verknüpft. ClaudeDo führt Aufgaben in einem Git-Repo aus.",
|
||||
"noRepoBannerButton": "Repo importieren",
|
||||
"importTicketsResult": "{0} Tickets geprüft, {1} neue Tasks."
|
||||
},
|
||||
"details": {
|
||||
"deleteTaskTip": "Aufgabe löschen",
|
||||
"killSessionTip": "Laufende Sitzung beenden",
|
||||
"killSessionTip": "Laufende Sitzung hart beenden",
|
||||
"sendToQueue": "In Warteschlange einreihen",
|
||||
"sendToQueueTip": "Diese Aufgabe in die Warteschlange einreihen, damit Claude sie beim nächsten Lauf übernimmt",
|
||||
"copyTaskIdTip": "Aufgaben-ID kopieren",
|
||||
"starTip": "Favorit",
|
||||
"agentSettingsTip": "Agent-Einstellungen",
|
||||
"agentSettingsHeading": "Agent-Einstellungen (Überschreibungen)",
|
||||
"waitingOnPrefix": "Wartet auf",
|
||||
"copyFormattedTip": "Titel, Beschreibung und offene Schritte kopieren",
|
||||
"toggleEditPreviewTip": "Bearbeiten/Vorschau umschalten",
|
||||
"planDay": "Tag planen",
|
||||
@@ -300,7 +307,36 @@
|
||||
"steps": "Schritte",
|
||||
"files": "Dateien"
|
||||
},
|
||||
"emptyState": "Wähle eine Aufgabe aus, um Details zu sehen"
|
||||
"emptyState": "Wähle eine Aufgabe aus, um Details zu sehen",
|
||||
"roadblockLabel": "ROADBLOCK",
|
||||
"stepsCard": {
|
||||
"preview": "Vorschau",
|
||||
"edit": "Bearbeiten",
|
||||
"addStepPlaceholder": "Schritt hinzufügen…"
|
||||
},
|
||||
"workConsole": {
|
||||
"continue": "Fortsetzen",
|
||||
"resetAndRetry": "Zurücksetzen & erneut versuchen",
|
||||
"tabOutput": "Ausgabe",
|
||||
"tabGit": "Git",
|
||||
"tabSession": "Sitzung",
|
||||
"outcomeLabel": "ERGEBNIS",
|
||||
"reviewLabel": "REVIEW",
|
||||
"mergeLabel": "MERGE",
|
||||
"targetBranch": "Zielbranch",
|
||||
"openDiff": "Diff öffnen",
|
||||
"worktree": "Worktree",
|
||||
"reviewCombinedDiff": "Kombinierten Diff prüfen",
|
||||
"openDiffHint": "Diff öffnen, um Mergen freizuschalten",
|
||||
"approveMerge": "Genehmigen & Mergen",
|
||||
"park": "Parken",
|
||||
"parkTip": "Zurückstellen — zurück auf Leerlauf, Worktree bleibt erhalten",
|
||||
"cancel": "Abbrechen",
|
||||
"resetDiscardBranch": "Zurücksetzen (Branch verwerfen)…",
|
||||
"submitForReviewHint": "Von Hand daran gearbeitet? Den Worktree zum Review einreichen, um ihn zu mergen.",
|
||||
"submitForReview": "Zum Review einreichen",
|
||||
"outcomesLabel": "ERGEBNISSE"
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"openWorktreeTip": "Worktree im Datei-Explorer öffnen — benötigt einen aktiven Worktree",
|
||||
@@ -343,10 +379,13 @@
|
||||
"browseForFolder": "Ordner wählen…",
|
||||
"focusMode": "Fokus",
|
||||
"overviewMode": "Übersicht",
|
||||
"closeSession": "Sitzung beenden",
|
||||
"closeSession": "Sitzung hart beenden",
|
||||
"closeSessionConfirm": "Laufende Sitzung beenden? Der Claude-Prozess wird abgebrochen.",
|
||||
"conptyLaunchFailed": "Interaktive Sitzung konnte nicht geöffnet werden: {0}",
|
||||
"conptyStarting": "Sitzung wird gestartet…",
|
||||
"conptyExited": "Sitzung beendet (Code {0}).",
|
||||
"usageWarningNotDelivered": "Die Listen-Handler-Sitzung konnte nicht vor dem Usage-Limit gewarnt werden.",
|
||||
"terminalSendFailed": "Konnte nicht an die Terminal-Sitzung gesendet werden.",
|
||||
"mergeHelperTitle": "Merge-Helfer",
|
||||
"mergeHelperHandoffTitleSuffix": " (Übergabe)",
|
||||
"mergeHelperTaskTitle": "Listen-Handler: {0}",
|
||||
@@ -364,7 +403,7 @@
|
||||
"queueRefreshFailed": "Warteschlange konnte nicht aktualisiert werden: {0}",
|
||||
"abandonRun": "Lauf abbrechen",
|
||||
"abandonRunTip": "Die noch offenen Tasks dieses Laufs abbrechen und die Sitzung schließen",
|
||||
"abandonRunConfirm": "{0} offene(n) Task(s) abbrechen und diese Sitzung schließen? Tasks im Review oder bereits erledigt bleiben unangetastet.",
|
||||
"abandonRunConfirm": "{0} offene Aufgabe(n) abbrechen und diese Sitzung schließen? Aufgaben im Review oder bereits erledigt bleiben unangetastet.",
|
||||
"abandonRunFailed": "Offene Tasks konnten nicht abgebrochen werden: {0}",
|
||||
"question": {
|
||||
"title": "Claude fragt nach",
|
||||
@@ -399,11 +438,18 @@
|
||||
"body": "ClaudeDo kann den Hintergrund-Worker nicht erreichen. Normalerweise wird er bei der Anmeldung automatisch gestartet. Du kannst ihn jetzt starten oder neu installieren, falls das Problem bestehen bleibt.",
|
||||
"dismiss": "Ausblenden",
|
||||
"rerunInstaller": "Installer erneut ausführen",
|
||||
"startWorker": "Worker starten"
|
||||
"startWorker": "Worker starten",
|
||||
"workerNotFound": "Worker-Programm nicht gefunden. Installation prüfen.",
|
||||
"installerNotFound": "Installer nicht gefunden. Installation prüfen.",
|
||||
"startFailed": "Start fehlgeschlagen: {0}"
|
||||
},
|
||||
"listSettings": {
|
||||
"title": "LISTENEINSTELLUNGEN",
|
||||
"folderTitle": "ORDNER",
|
||||
"deleteList": "Liste löschen",
|
||||
"deleteFolder": "Ordner löschen",
|
||||
"createAsFolder": "Stattdessen einen Ordner anlegen",
|
||||
"createAsFolderHint": "Ein Ordner gruppiert nur Listen in der Seitenleiste — er enthält keine Aufgaben und führt nichts aus. Listen per Drag-and-Drop hineinziehen.",
|
||||
"sectionGeneral": "ALLGEMEIN",
|
||||
"name": "Name",
|
||||
"workingDirectory": "Arbeitsverzeichnis",
|
||||
@@ -435,7 +481,8 @@
|
||||
"targetBranch": "Ziel-Branch",
|
||||
"removeWorktree": "Worktree nach dem Mergen entfernen",
|
||||
"commitMessage": "Commit-Nachricht",
|
||||
"conflictedFiles": "Konfliktdateien:"
|
||||
"conflictedFiles": "Konfliktdateien:",
|
||||
"stashAndMerge": "Änderungen stashen und mergen"
|
||||
},
|
||||
"diff": {
|
||||
"title": "DIFF",
|
||||
@@ -491,6 +538,8 @@
|
||||
"windowTitle": "Claude machen lassen",
|
||||
"scopeList": "Liste: {0}",
|
||||
"scopeReview": "Alle Listen — Tasks im Review",
|
||||
"modelLabel": "Modell",
|
||||
"modelTip": "Modell für die Triage-Session (Dedupe & Verfeinern). Spätere Merge-Phasen laufen immer auf Opus.",
|
||||
"confirm": "Claude machen lassen",
|
||||
"cancel": "Abbrechen",
|
||||
"selectAll": "Alle auswählen",
|
||||
@@ -531,14 +580,14 @@
|
||||
"noGauges": "Noch keine Nutzungslimits gemeldet.",
|
||||
"refreshHint": "Jetzt aktualisieren — Abruf alle 5 Min., während ein Task läuft, sonst alle 15 Min.",
|
||||
"staleFormat": "Werte veraltet (Stand {0})",
|
||||
"staleGateHint": "Das Gate greift in diesem Zustand nicht.",
|
||||
"staleGateHint": "Der Limit-Stopp greift in diesem Zustand nicht.",
|
||||
"gateBlockedFormat": "Warteschlange pausiert — {0}",
|
||||
"throttleFormat": "Warteschlange gedrosselt: {0}/{1} Slots ({2})",
|
||||
"resetIn": "Reset in {0} ({1})",
|
||||
"dragHint": "Marker ziehen oder Wert unten eintragen.",
|
||||
"legendSoft": "Soft · 2 Slots",
|
||||
"legendHard": "Hard · 1 Slot",
|
||||
"legendGate": "Gate · Pause",
|
||||
"legendGate": "Limit-Stopp · Pause",
|
||||
"gaugeSession": "Session (5 Std.)",
|
||||
"gaugeWeeklyAll": "Woche (alle Modelle)",
|
||||
"gaugeWeeklyScopedFormat": "Woche ({0})",
|
||||
@@ -558,7 +607,7 @@
|
||||
"columnOtherOut": "ÜBRIGES OUT",
|
||||
"columnOtherCache": "ÜBRIGES CACHE",
|
||||
"columnShare": "ANTEIL",
|
||||
"columnTask": "TASK",
|
||||
"columnTask": "AUFGABE",
|
||||
"columnList": "LISTE",
|
||||
"columnModelShort": "MODELL",
|
||||
"columnRuns": "RUNS",
|
||||
@@ -662,35 +711,61 @@
|
||||
"checks": {
|
||||
"git": {
|
||||
"title": "Git",
|
||||
"hint": "Installiere Git und stelle sicher, dass es im PATH liegt."
|
||||
"hint": "Installiere Git von https://git-scm.com/downloads und starte den Installer neu.",
|
||||
"notFound": "git wurde nicht im PATH gefunden.",
|
||||
"exitCode": "'{0}' wurde mit Exitcode {1} beendet.",
|
||||
"resolvedFormat": "{0} — {1}"
|
||||
},
|
||||
"gitIdentity": {
|
||||
"title": "Git-Identität",
|
||||
"hint": "Lege deine Git-Identität fest: git config --global user.name \"...\" und user.email \"...\"."
|
||||
"hint": "Führe aus: git config --global user.name \"Dein Name\" und git config --global user.email \"du@example.com\"",
|
||||
"notFound": "git wurde nicht gefunden — Identität konnte nicht geprüft werden.",
|
||||
"missing": "Fehlende Git-Identität: {0}."
|
||||
},
|
||||
"ports": {
|
||||
"title": "Ports",
|
||||
"hint": "Gib den Port frei oder ändere ihn nach der Installation auf der Worker-Seite."
|
||||
"hint": "Ändere die SignalR-/MCP-Ports auf der Dienste-Seite, oder beende den blockierenden Prozess.",
|
||||
"inUse": "{0}-Port {1} ist belegt.",
|
||||
"inUseBy": "{0}-Port {1} ist belegt von '{2}'.",
|
||||
"inUseByOwnWorker": "Belegt vom laufenden ClaudeDo Worker: {0}.",
|
||||
"available": "Ports sind verfügbar."
|
||||
},
|
||||
"writeAccess": {
|
||||
"title": "Schreibzugriff",
|
||||
"hint": "Wähle ein anderes Installationsverzeichnis oder starte den Installer als Administrator."
|
||||
"hint": "Wähle ein anderes Installationsverzeichnis oder starte den Installer mit ausreichenden Rechten (z. B. als Administrator).",
|
||||
"cannotWrite": "Kein Schreibzugriff auf '{0}': {1}",
|
||||
"writable": "Installations- und Datenverzeichnis sind beschreibbar."
|
||||
},
|
||||
"claudeCli": {
|
||||
"title": "Claude CLI",
|
||||
"hint": "Installiere die Claude CLI und stelle sicher, dass sie im PATH liegt."
|
||||
"hint": "Installiere die Claude CLI (npm install -g @anthropic-ai/claude-code) und starte danach dein Terminal/deine Anmeldesitzung neu, damit der aktualisierte PATH greift.",
|
||||
"notFound": "'{0}' wurde nicht im PATH gefunden. Durchsucht: {1}.",
|
||||
"exitCode": "'{0}' wurde mit Exitcode {1} beendet.",
|
||||
"resolvedFormat": "{0} — {1}"
|
||||
},
|
||||
"claudeVersion": {
|
||||
"title": "Claude-CLI-Version",
|
||||
"hint": "Aktualisiere die Claude CLI auf eine neuere Version."
|
||||
"hint": "Aktualisiere die CLI: npm update -g @anthropic-ai/claude-code",
|
||||
"notFound": "Die claude-CLI wurde nicht gefunden — Version konnte nicht geprüft werden.",
|
||||
"parseFailed": "Konnte keine Version aus '{0}' auslesen.",
|
||||
"tooOld": "claude {0} ist älter als die erforderliche Version {1}.",
|
||||
"ok": "claude {0} erfüllt die Mindestanforderung ({1})."
|
||||
},
|
||||
"claudeAuth": {
|
||||
"title": "Claude-Anmeldung",
|
||||
"hint": "Führe 'claude auth login' aus, um dich anzumelden."
|
||||
"title": "Claude-CLI-Login",
|
||||
"hint": "Starte 'claude' einmal und schließe den Login-Vorgang ab.",
|
||||
"notFound": "Die claude-CLI wurde nicht gefunden — Login konnte nicht geprüft werden.",
|
||||
"undetermined": "Login-Status konnte nicht ermittelt werden.",
|
||||
"notLoggedIn": "Nicht angemeldet.",
|
||||
"loggedIn": "Angemeldet."
|
||||
},
|
||||
"permissionModeAuto": {
|
||||
"title": "Berechtigungsmodus",
|
||||
"hint": "Aktualisiere die Claude CLI auf eine Version, die --permission-mode auto unterstützt."
|
||||
"hint": "Aktualisiere die CLI: npm update -g @anthropic-ai/claude-code. ClaudeDo funktioniert auch im Modus acceptEdits/default, dann mit mehr Rückfragen.",
|
||||
"notFound": "Die claude-CLI wurde nicht gefunden — Unterstützung für permission-mode konnte nicht geprüft werden.",
|
||||
"undetermined": "Konnte nicht ermitteln, ob --permission-mode auto unterstützt wird.",
|
||||
"ok": "--permission-mode auto wird von dieser CLI erkannt.",
|
||||
"notListed": "Diese claude-CLI-Version listet 'auto' nicht als --permission-mode-Option."
|
||||
}
|
||||
},
|
||||
"planning": {
|
||||
@@ -737,6 +812,8 @@
|
||||
"repositories": "Repositories",
|
||||
"checkForUpdates": "Nach Updates suchen",
|
||||
"restartWorker": "Worker neu starten",
|
||||
"updateClaudeCli": "Claude CLI aktualisieren",
|
||||
"shutdown": "Beenden",
|
||||
"worktrees": "Worktrees…",
|
||||
"weeklyReport": "Wochenbericht…",
|
||||
"usageMonitor": "Usage Monitor…",
|
||||
@@ -762,10 +839,10 @@
|
||||
},
|
||||
"vm": {
|
||||
"connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" },
|
||||
"shell": { "restartingWorker": "Worker wird neu gestartet…", "unexpectedError": "Unerwarteter Fehler: {0}" },
|
||||
"shell": { "unexpectedError": "Unerwarteter Fehler: {0}", "shutdownConfirm": "Dadurch wird der ClaudeDo-Worker beendet (laufende Task-Runs und interaktive Sitzungen brechen ab) und die App geschlossen. Fortfahren?" },
|
||||
"mainWindow": { "closeConfirm": { "message": "{0} Mission-Control-Sitzung(en) laufen noch. Beim Beenden werden sie abgebrochen. Trotzdem schließen?" } },
|
||||
"agentStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "review": "Prüfung", "children": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen" },
|
||||
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt", "interactive": "Interaktiv" },
|
||||
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt", "interactive": "Interaktiv", "waiting": "Wartet" },
|
||||
"failureReason": { "maxTurns": "Turn-Limit erreicht", "timeout": "Zeitüberschreitung", "usageLimit": "Nutzungslimit erreicht", "error": "Fehler", "cancelled": "Abgebrochen", "unknown": "Grund unbekannt" },
|
||||
"failureReasonTooltip": { "maxTurns": "Turn-Limit erreicht ({0}/{1} Turns) — der Worktree ist meist brauchbar; Task fortsetzen statt zurücksetzen." },
|
||||
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
|
||||
@@ -774,7 +851,7 @@
|
||||
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "statusPill": "{0} laufend · {1} Prüfung", "openTaskOne": "1 offene Aufgabe", "openTasks": "{0} offene Aufgaben", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}", "setStatusFailed": "Status konnte nicht aktualisiert werden: {0}", "cancelFailed": "Abbrechen fehlgeschlagen: {0}", "rejectToQueueFailed": "Zurückweisen in die Warteschlange fehlgeschlagen: {0}", "rejectToIdleFailed": "Zurückweisen fehlgeschlagen: {0}", "deleteTaskConfirm": "\"{0}\" löschen? Dies kann nicht rückgängig gemacht werden.", "deleteTaskFailed": "Löschen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}", "removeFromQueueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
|
||||
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
|
||||
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" },
|
||||
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
|
||||
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "dirtyTarget": "Merge abgelehnt — das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
|
||||
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
|
||||
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
|
||||
"tickets": { "connectionOk": "Verbunden als {0}.", "connectionMissingScopes": "Verbunden als {0}, aber es fehlen Scopes: {1}", "tokenCleared": "Token entfernt.", "workerOffline": "Worker nicht erreichbar." },
|
||||
@@ -793,12 +870,12 @@
|
||||
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
|
||||
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" },
|
||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardConfirm": "Worktree für \"{0}\" verwerfen? Nicht committete Arbeit geht verloren.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchDone": "{0} gemergt, {1} zu lösen.", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" },
|
||||
"listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —", "loadFailed": "Listeneinstellungen konnten nicht geladen werden: {0}", "ticketProjectsLoadFailed": "Ticket-Projekte konnten nicht geladen werden: {0}" },
|
||||
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" },
|
||||
"listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —", "loadFailed": "Listeneinstellungen konnten nicht geladen werden: {0}", "ticketProjectsLoadFailed": "Ticket-Projekte konnten nicht geladen werden: {0}", "confirmDeleteFolder": "Ordner \"{0}\" löschen? Die Listen darin bleiben erhalten und wandern auf die oberste Ebene.", "confirmDeleteList": "Liste \"{0}\" und alle ihre Aufgaben löschen? Das kann nicht rückgängig gemacht werden.", "deleteBlockedPlanning": "Diese Liste hat Planungssitzungen mit Kind-Aufgaben. Erst diese verwerfen, dann die Liste löschen.", "workingDirMissing": "Dieser Ordner existiert nicht.", "workingDirNotRepo": "Dieser Ordner ist noch kein Git-Repository. Führe darin zuerst `git init` aus, oder leere das Arbeitsverzeichnis, um dies als manuelle Liste zu speichern." },
|
||||
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "dirtyTarget": "Merge abgelehnt — das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.", "stashAndMergeConfirm": "Das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.\nStashen, mergen und danach wiederherstellen?", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" },
|
||||
"mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
|
||||
"about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" },
|
||||
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "smartNotes": "Notizen", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste", "findingsNotFound": "Für diese Liste gibt es noch keine Findings.", "findingsOpenFailed": "Findings-Ordner konnte nicht geöffnet werden: {0}", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" },
|
||||
"repoImport": { "loadFailed": "Gespeicherte Ordner konnten nicht geladen werden: {0}", "saveFailed": "Ordner konnten nicht gespeichert werden: {0}" }
|
||||
"repoImport": { "loadFailed": "Gespeicherte Ordner konnten nicht geladen werden: {0}", "saveFailed": "Ordner konnten nicht gespeichert werden: {0}", "createButton": "{0} Liste(n) erstellen" }
|
||||
},
|
||||
"ops": {
|
||||
"stalledHint": "Läuft weiter — Worker antwortet noch",
|
||||
@@ -866,39 +943,5 @@
|
||||
"lastError": "Letzter Fehler: {0}",
|
||||
"throttled": "Gedrosselt: {0}/{1} Slots ({2})"
|
||||
}
|
||||
},
|
||||
"checks": {
|
||||
"git": {
|
||||
"title": "Git",
|
||||
"hint": "Installiere Git von https://git-scm.com/downloads und starte den Installer neu."
|
||||
},
|
||||
"gitIdentity": {
|
||||
"title": "Git-Identität",
|
||||
"hint": "Führe aus: git config --global user.name \"Dein Name\" und git config --global user.email \"du@example.com\""
|
||||
},
|
||||
"ports": {
|
||||
"title": "Ports",
|
||||
"hint": "Ändere die SignalR-/MCP-Ports auf der Dienste-Seite, oder beende den blockierenden Prozess."
|
||||
},
|
||||
"writeAccess": {
|
||||
"title": "Schreibzugriff",
|
||||
"hint": "Wähle ein anderes Installationsverzeichnis oder starte den Installer mit ausreichenden Rechten (z. B. als Administrator)."
|
||||
},
|
||||
"claudeCli": {
|
||||
"title": "Claude CLI",
|
||||
"hint": "Installiere die Claude CLI (npm install -g @anthropic-ai/claude-code) und starte danach dein Terminal/deine Anmeldesitzung neu, damit der aktualisierte PATH greift."
|
||||
},
|
||||
"claudeVersion": {
|
||||
"title": "Claude-CLI-Version",
|
||||
"hint": "Aktualisiere die CLI: npm update -g @anthropic-ai/claude-code"
|
||||
},
|
||||
"claudeAuth": {
|
||||
"title": "Claude-CLI-Login",
|
||||
"hint": "Starte 'claude' einmal und schließe den Login-Vorgang ab."
|
||||
},
|
||||
"permissionModeAuto": {
|
||||
"title": "Unterstützung für --permission-mode auto",
|
||||
"hint": "Aktualisiere die CLI: npm update -g @anthropic-ai/claude-code. ClaudeDo funktioniert auch im Modus acceptEdits/default, dann mit mehr Rückfragen."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
"categoryGeneral": "General",
|
||||
"categoryExecution": "Execution",
|
||||
"categoryPrime": "Prime Claude",
|
||||
"categoryPrimeHint": "Scheduled Claude runs: Ping, Fill My Day, or a custom prompt, at fixed times.",
|
||||
"categoryWorktrees": "Worktrees",
|
||||
"categoryFiles": "Files & Prompts",
|
||||
"categorySkills": "Session Skills",
|
||||
@@ -27,6 +28,7 @@
|
||||
"model": "Model",
|
||||
"maxTurns": "Max turns",
|
||||
"effort": "Effort",
|
||||
"effortHint": "Reasoning effort the model applies. Higher costs more and reasons deeper.",
|
||||
"modelPresets": "Per-model defaults",
|
||||
"modelPresetsHint": "Reasoning effort and turn budget used when a session runs under that model. List- and task-level max-turn overrides still win.",
|
||||
"maxTurnsCeiling": "Max turns ceiling",
|
||||
@@ -50,6 +52,7 @@
|
||||
"powerUseWindowPlaceholder": "HH:mm",
|
||||
"powerUseHandoffPct": "Hand off at (% of 5h)",
|
||||
"powerUseWindowHint": "Leave both times empty for round the clock. A window is checked whenever a handler phase starts, so a chain that runs past the end time continues without PowerUse.",
|
||||
"powerUseWindowInvalid": "PowerUse window times must be HH:mm, or left blank.",
|
||||
"throttleStagesHeading": "Throttle stages",
|
||||
"throttleStagesHint": "Parallelism is capped before the hard gate: soft caps at 2 slots, hard at 1. Edited by dragging the markers on the usage-monitor gauges. Opening the monitor closes these settings — save first if you changed anything.",
|
||||
"throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%",
|
||||
@@ -239,6 +242,7 @@
|
||||
"toggleSubtasksTip": "Expand / collapse subtasks",
|
||||
"agentSuggestedTip": "Suggested by the agent",
|
||||
"interactiveChipTip": "Interactive session open — click to jump to it in Mission Control",
|
||||
"waitingOnTip": "Waiting on {0}",
|
||||
"scheduleTitle": "Schedule task",
|
||||
"scheduleWhen": "WHEN",
|
||||
"scheduleConfirm": "Schedule",
|
||||
@@ -260,6 +264,8 @@
|
||||
"contextOpenFindings": "Open findings folder",
|
||||
"contextImportTickets": "Update from ticket system",
|
||||
"contextSettings": "Settings…",
|
||||
"contextFolderSettings": "Folder settings…",
|
||||
"contextRemoveFromFolder": "Move out of folder",
|
||||
"newList": "New list",
|
||||
"addReposTip": "Add repos as lists",
|
||||
"noRepoBannerText": "No repo linked yet. ClaudeDo runs tasks in a git repo.",
|
||||
@@ -275,6 +281,7 @@
|
||||
"starTip": "Star",
|
||||
"agentSettingsTip": "Agent settings",
|
||||
"agentSettingsHeading": "Agent settings (overrides)",
|
||||
"waitingOnPrefix": "Waiting on",
|
||||
"copyFormattedTip": "Copy title, description and open steps",
|
||||
"toggleEditPreviewTip": "Toggle edit/preview",
|
||||
"planDay": "Plan day",
|
||||
@@ -300,7 +307,36 @@
|
||||
"steps": "Steps",
|
||||
"files": "Files"
|
||||
},
|
||||
"emptyState": "Select a task to see its details"
|
||||
"emptyState": "Select a task to see its details",
|
||||
"roadblockLabel": "ROADBLOCK",
|
||||
"stepsCard": {
|
||||
"preview": "Preview",
|
||||
"edit": "Edit",
|
||||
"addStepPlaceholder": "Add step…"
|
||||
},
|
||||
"workConsole": {
|
||||
"continue": "Continue",
|
||||
"resetAndRetry": "Reset & Retry",
|
||||
"tabOutput": "Output",
|
||||
"tabGit": "Git",
|
||||
"tabSession": "Session",
|
||||
"outcomeLabel": "OUTCOME",
|
||||
"reviewLabel": "REVIEW",
|
||||
"mergeLabel": "MERGE",
|
||||
"targetBranch": "Target branch",
|
||||
"openDiff": "Open Diff",
|
||||
"worktree": "Worktree",
|
||||
"reviewCombinedDiff": "Review Combined Diff",
|
||||
"openDiffHint": "Open the diff to enable merge",
|
||||
"approveMerge": "Approve & Merge",
|
||||
"park": "Park",
|
||||
"parkTip": "Set aside — back to Idle, keeps the worktree",
|
||||
"cancel": "Cancel",
|
||||
"resetDiscardBranch": "Reset (discard branch)…",
|
||||
"submitForReviewHint": "Worked on this by hand? Submit the worktree for review to merge it.",
|
||||
"submitForReview": "Submit for review",
|
||||
"outcomesLabel": "OUTCOMES"
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"openWorktreeTip": "Open worktree in file explorer — requires an active worktree",
|
||||
@@ -344,9 +380,12 @@
|
||||
"focusMode": "Focus",
|
||||
"overviewMode": "Overview",
|
||||
"closeSession": "Kill session",
|
||||
"closeSessionConfirm": "End the running session? The Claude process will be aborted.",
|
||||
"conptyLaunchFailed": "Couldn't open interactive session: {0}",
|
||||
"conptyStarting": "Starting session…",
|
||||
"conptyExited": "Session ended (code {0}).",
|
||||
"usageWarningNotDelivered": "Couldn't warn the list-handler session about the usage limit.",
|
||||
"terminalSendFailed": "Couldn't send that to the terminal session.",
|
||||
"mergeHelperTitle": "Merge Helper",
|
||||
"mergeHelperHandoffTitleSuffix": " (Handoff)",
|
||||
"mergeHelperTaskTitle": "List handler: {0}",
|
||||
@@ -399,11 +438,18 @@
|
||||
"body": "ClaudeDo can't reach the background worker. It is normally started automatically at logon. You can start it now, or reinstall if the problem persists.",
|
||||
"dismiss": "Dismiss",
|
||||
"rerunInstaller": "Rerun Installer",
|
||||
"startWorker": "Start Worker"
|
||||
"startWorker": "Start Worker",
|
||||
"workerNotFound": "Worker executable not found. Check the installation.",
|
||||
"installerNotFound": "Installer not found. Check the installation.",
|
||||
"startFailed": "Start failed: {0}"
|
||||
},
|
||||
"listSettings": {
|
||||
"title": "LIST SETTINGS",
|
||||
"folderTitle": "FOLDER",
|
||||
"deleteList": "Delete list",
|
||||
"deleteFolder": "Delete folder",
|
||||
"createAsFolder": "Create a folder instead",
|
||||
"createAsFolderHint": "A folder just groups lists in the sidebar — it holds no tasks and runs nothing. Drag lists onto it to fill it.",
|
||||
"sectionGeneral": "GENERAL",
|
||||
"name": "Name",
|
||||
"workingDirectory": "Working directory",
|
||||
@@ -435,7 +481,8 @@
|
||||
"targetBranch": "Target branch",
|
||||
"removeWorktree": "Remove worktree after merge",
|
||||
"commitMessage": "Commit message",
|
||||
"conflictedFiles": "Conflicted files:"
|
||||
"conflictedFiles": "Conflicted files:",
|
||||
"stashAndMerge": "Stash changes and merge"
|
||||
},
|
||||
"diff": {
|
||||
"title": "DIFF",
|
||||
@@ -491,6 +538,8 @@
|
||||
"windowTitle": "Let Claude handle it",
|
||||
"scopeList": "List: {0}",
|
||||
"scopeReview": "All lists — tasks waiting for review",
|
||||
"modelLabel": "Model",
|
||||
"modelTip": "Model for the triage session (dedupe & refine). Later merge phases always run on opus.",
|
||||
"confirm": "Let Claude handle it",
|
||||
"cancel": "Cancel",
|
||||
"selectAll": "Select all",
|
||||
@@ -662,35 +711,61 @@
|
||||
"checks": {
|
||||
"git": {
|
||||
"title": "Git",
|
||||
"hint": "Install Git and make sure it is on PATH."
|
||||
"hint": "Install Git from https://git-scm.com/downloads, then restart the installer.",
|
||||
"notFound": "git was not found on PATH.",
|
||||
"exitCode": "'{0}' exited with code {1}.",
|
||||
"resolvedFormat": "{0} — {1}"
|
||||
},
|
||||
"gitIdentity": {
|
||||
"title": "Git Identity",
|
||||
"hint": "Set your git identity: git config --global user.name \"...\" and user.email \"...\"."
|
||||
"hint": "Run: git config --global user.name \"Your Name\" and git config --global user.email \"you@example.com\"",
|
||||
"notFound": "git was not found — identity could not be checked.",
|
||||
"missing": "Missing git identity: {0}."
|
||||
},
|
||||
"ports": {
|
||||
"title": "Ports",
|
||||
"hint": "Free the port, or change it on the Worker page after install."
|
||||
"hint": "Change the SignalR/MCP ports on the Service page, or stop the process using them.",
|
||||
"inUse": "{0} port {1} is in use.",
|
||||
"inUseBy": "{0} port {1} is in use by '{2}'.",
|
||||
"inUseByOwnWorker": "In use by the running ClaudeDo Worker: {0}.",
|
||||
"available": "Ports are available."
|
||||
},
|
||||
"writeAccess": {
|
||||
"title": "Write Access",
|
||||
"hint": "Choose a different install directory, or run the installer as administrator."
|
||||
"hint": "Choose a different install directory, or run the installer with sufficient permissions (e.g. as administrator).",
|
||||
"cannotWrite": "Cannot write to '{0}': {1}",
|
||||
"writable": "Install directory and data directory are writable."
|
||||
},
|
||||
"claudeCli": {
|
||||
"title": "Claude CLI",
|
||||
"hint": "Install the Claude CLI and make sure it is on PATH."
|
||||
"hint": "Install the Claude CLI (npm install -g @anthropic-ai/claude-code), then restart your terminal/login session so the updated PATH takes effect.",
|
||||
"notFound": "'{0}' was not found on PATH. Searched: {1}.",
|
||||
"exitCode": "'{0}' exited with code {1}.",
|
||||
"resolvedFormat": "{0} — {1}"
|
||||
},
|
||||
"claudeVersion": {
|
||||
"title": "Claude CLI Version",
|
||||
"hint": "Update the Claude CLI to a newer version."
|
||||
"hint": "Update the CLI: npm update -g @anthropic-ai/claude-code",
|
||||
"notFound": "The claude CLI was not found — version could not be checked.",
|
||||
"parseFailed": "Could not parse a version from '{0}'.",
|
||||
"tooOld": "claude {0} is older than the required {1}.",
|
||||
"ok": "claude {0} meets the minimum ({1})."
|
||||
},
|
||||
"claudeAuth": {
|
||||
"title": "Claude Login",
|
||||
"hint": "Run 'claude auth login' to sign in."
|
||||
"title": "Claude CLI Login",
|
||||
"hint": "Run 'claude' once and complete the login flow.",
|
||||
"notFound": "The claude CLI was not found — login could not be checked.",
|
||||
"undetermined": "Login status could not be determined.",
|
||||
"notLoggedIn": "Not logged in.",
|
||||
"loggedIn": "Logged in."
|
||||
},
|
||||
"permissionModeAuto": {
|
||||
"title": "Permission Mode",
|
||||
"hint": "Update the Claude CLI to a version that supports --permission-mode auto."
|
||||
"hint": "Update the CLI: npm update -g @anthropic-ai/claude-code. ClaudeDo still works in acceptEdits/default mode, with more prompts.",
|
||||
"notFound": "The claude CLI was not found — permission-mode support could not be checked.",
|
||||
"undetermined": "Could not determine whether --permission-mode auto is supported.",
|
||||
"ok": "--permission-mode auto is recognized by this CLI.",
|
||||
"notListed": "This claude CLI version does not list 'auto' as a --permission-mode choice."
|
||||
}
|
||||
},
|
||||
"planning": {
|
||||
@@ -737,6 +812,8 @@
|
||||
"repositories": "Repositories",
|
||||
"checkForUpdates": "Check for updates",
|
||||
"restartWorker": "Restart worker",
|
||||
"updateClaudeCli": "Update Claude CLI",
|
||||
"shutdown": "Shutdown",
|
||||
"worktrees": "Worktrees…",
|
||||
"weeklyReport": "Weekly Report…",
|
||||
"usageMonitor": "Usage Monitor…",
|
||||
@@ -762,10 +839,10 @@
|
||||
},
|
||||
"vm": {
|
||||
"connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" },
|
||||
"shell": { "restartingWorker": "Restarting worker…", "unexpectedError": "Unexpected error: {0}" },
|
||||
"shell": { "unexpectedError": "Unexpected error: {0}", "shutdownConfirm": "This stops the ClaudeDo worker (any running task or interactive session is interrupted) and closes the app. Continue?" },
|
||||
"mainWindow": { "closeConfirm": { "message": "{0} Mission Control session(s) are still running. Closing the app will end them. Close anyway?" } },
|
||||
"agentStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "review": "Review", "children": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" },
|
||||
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked", "interactive": "Interactive" },
|
||||
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked", "interactive": "Interactive", "waiting": "Waiting" },
|
||||
"failureReason": { "maxTurns": "Turn limit reached", "timeout": "Timed out", "usageLimit": "Usage limit reached", "error": "Error", "cancelled": "Cancelled", "unknown": "Unknown reason" },
|
||||
"failureReasonTooltip": { "maxTurns": "Turn limit reached ({0}/{1} turns) — the worktree is usually fine; continue the task instead of resetting it." },
|
||||
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
|
||||
@@ -774,7 +851,7 @@
|
||||
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "statusPill": "{0} running · {1} review", "openTaskOne": "1 open task", "openTasks": "{0} open tasks", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}", "setStatusFailed": "Failed to update status: {0}", "cancelFailed": "Cancel failed: {0}", "rejectToQueueFailed": "Reject to queue failed: {0}", "rejectToIdleFailed": "Reject failed: {0}", "deleteTaskConfirm": "Delete \"{0}\"? This cannot be undone.", "deleteTaskFailed": "Delete failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}", "removeFromQueueFailed": "Remove from queue failed: {0}", "openWorktreeFailed": "Couldn't open worktree: {0}" },
|
||||
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
|
||||
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" },
|
||||
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
|
||||
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "dirtyTarget": "Merge refused — the target working directory has uncommitted changes.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
|
||||
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
|
||||
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
|
||||
"tickets": { "connectionOk": "Connected as {0}.", "connectionMissingScopes": "Connected as {0}, but missing scopes: {1}", "tokenCleared": "Token removed.", "workerOffline": "Worker not reachable." },
|
||||
@@ -793,12 +870,12 @@
|
||||
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" },
|
||||
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" },
|
||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardConfirm": "Discard the worktree for \"{0}\"? Uncommitted work will be lost.", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchDone": "Merged {0}, {1} need resolution.", "openInExplorerFailed": "Couldn't open path: {0}" },
|
||||
"listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —", "loadFailed": "Couldn't load list settings: {0}", "ticketProjectsLoadFailed": "Couldn't load ticket projects: {0}" },
|
||||
"detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" },
|
||||
"listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —", "loadFailed": "Couldn't load list settings: {0}", "ticketProjectsLoadFailed": "Couldn't load ticket projects: {0}", "confirmDeleteFolder": "Delete folder \"{0}\"? The lists inside it are kept and move back to the top level.", "confirmDeleteList": "Delete list \"{0}\" and all its tasks? This cannot be undone.", "deleteBlockedPlanning": "This list has planning sessions with child tasks. Discard those first, then delete the list.", "workingDirMissing": "This folder doesn't exist.", "workingDirNotRepo": "This folder isn't a git repository yet. Run `git init` in it first, or clear the working directory to save this as a manual list." },
|
||||
"detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "dirtyTarget": "Merge refused — the target working directory has uncommitted changes.", "stashAndMergeConfirm": "The target working directory has uncommitted changes.\nStash them, merge, and restore them afterwards?", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" },
|
||||
"mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" },
|
||||
"about": { "openPathFailed": "Couldn't open path: {0}" },
|
||||
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "smartNotes": "Notes", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list", "findingsNotFound": "No findings yet for this list.", "findingsOpenFailed": "Couldn't open findings folder: {0}", "openInExplorerFailed": "Couldn't open path: {0}" },
|
||||
"repoImport": { "loadFailed": "Couldn't load remembered folders: {0}", "saveFailed": "Couldn't save folders: {0}" }
|
||||
"repoImport": { "loadFailed": "Couldn't load remembered folders: {0}", "saveFailed": "Couldn't save folders: {0}", "createButton": "Create {0} list(s)" }
|
||||
},
|
||||
"ops": {
|
||||
"stalledHint": "Still running — worker hasn't responded in a while",
|
||||
@@ -866,39 +943,5 @@
|
||||
"lastError": "Last error: {0}",
|
||||
"throttled": "Throttled: {0}/{1} slots ({2})"
|
||||
}
|
||||
},
|
||||
"checks": {
|
||||
"git": {
|
||||
"title": "Git",
|
||||
"hint": "Install Git from https://git-scm.com/downloads, then restart the installer."
|
||||
},
|
||||
"gitIdentity": {
|
||||
"title": "Git identity",
|
||||
"hint": "Run: git config --global user.name \"Your Name\" and git config --global user.email \"you@example.com\""
|
||||
},
|
||||
"ports": {
|
||||
"title": "Ports",
|
||||
"hint": "Change the SignalR/MCP ports on the Service page, or stop the process using them."
|
||||
},
|
||||
"writeAccess": {
|
||||
"title": "Write access",
|
||||
"hint": "Choose a different install directory, or run the installer with sufficient permissions (e.g. as administrator)."
|
||||
},
|
||||
"claudeCli": {
|
||||
"title": "Claude CLI",
|
||||
"hint": "Install the Claude CLI (npm install -g @anthropic-ai/claude-code), then restart your terminal/login session so the updated PATH takes effect."
|
||||
},
|
||||
"claudeVersion": {
|
||||
"title": "Claude CLI version",
|
||||
"hint": "Update the CLI: npm update -g @anthropic-ai/claude-code"
|
||||
},
|
||||
"claudeAuth": {
|
||||
"title": "Claude CLI login",
|
||||
"hint": "Run 'claude' once and complete the login flow."
|
||||
},
|
||||
"permissionModeAuto": {
|
||||
"title": "--permission-mode auto support",
|
||||
"hint": "Update the CLI: npm update -g @anthropic-ai/claude-code. ClaudeDo still works in acceptEdits/default mode, with more prompts."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles)
|
||||
|
||||
- **IslandsShellViewModel** — root coordinator. Owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay; `FlashFooterError` surfaces UI-action failures + the worker's Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, the modal openers, and `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`.
|
||||
- **ListsIslandViewModel** — smart lists (My Day, Important, Planned, virtual queued/running/review), user lists, selection, list CRUD, drag-reorder, badge counts, opens list settings / repo import / worktrees overview, `OpenInExplorer`/`OpenInTerminal`.
|
||||
- **Folders:** the sidebar's top level is **one mixed collection**, `TopLevel` (`ObservableCollection<object>`: `ListFolderNavItemViewModel` rows, each with its own `Lists`, interleaved with folder-less `ListNavItemViewModel` rows) — that's what lets a list sit *above* a folder. `Folders`/`RootLists` are `OfType` **projections** of it, not collections to keep in sync. `UserLists` stays the **flat superset** every count refresh, row refresh and the no-repo banner work over — a row lives in both. `ContainerOf(row)` resolves which collection renders it (non-generic `IList`, since the top level is mixed). The view is a single `ItemsControl` over `TopLevel` with **implicit, type-matched `DataTemplate`s in `UserControl.DataTemplates`** — no keyed row template, and the folder's child `ItemsControl` resolves the same list template by walking up to the UserControl. A folder is never selectable: tapping the header runs `ToggleFolderCommand` (persisted to `list_folders.is_collapsed`), and its badge is the sum of its children. `DropAsync(source, target, placeBelow)` handles all four source/target combinations and doubles as the move: a drop position implies a container, so dropping a list onto a row in another folder — or next to *anything* at the top level, folder headers included — reassigns `FolderId`. A folder dropped onto a row inside a folder is ignored; folders never nest. `MoveToFolderAsync` is the drop-into-the-header path (reaches collapsed/empty folders, bound to the middle half of the header — the outer quarters insert *next to* the folder instead); `RemoveFromFolderCommand` is its context-menu counterpart. Both end in `PersistOrderAsync` → `ListRepository.ReorderSidebarAsync`, one `sort_order` sequence spanning **both tables**. ⚠️ A **folder** drag starts from `PointerMoved` past a 4px threshold using the *stored* `PointerPressedEventArgs` (`DoDragDropAsync` only accepts those) — starting it on press would capture the pointer and swallow the `Tapped` that folds the folder.
|
||||
- **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions; planning session lifecycle; `RefineTask`, `OpenConPtySessionRequested`, `ToggleManual`, `SyncInteractiveSessions`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row. Raises `NotesRequested`/`PrepRequested` for the shell.
|
||||
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, the session-outcome/roadblock split, the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` + `ChildrenNeedingAttention` drive an attention band on the Session tab), attachments, and the modes `IsNotesMode`/`IsPrepMode`/computed `IsTaskDetailVisible`. Failures raise `ErrorReported`, wired by the shell into `FlashFooterError`.
|
||||
- Three concerns are extracted into section VMs exposed as properties: `AgentSettings` (`AgentConfigEditorViewModel`, scope=Task), `MergeSectionViewModel`, `PrepPanelViewModel`. Helper rows live in the same file.
|
||||
@@ -55,7 +56,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles)
|
||||
| VM | Notes |
|
||||
|---|---|
|
||||
| `SettingsModalViewModel` | Four tabs: General, Worktrees, Files (prompt paths), Prime Claude. General hosts the per-model preset table (`ModelPresetRowViewModel`: effort + max turns per alias) which **replaced** the single global "Max turns" field. The Prime Claude tab gives each schedule row a three-way action radio group (Ping / Fill My Day / Custom) — `RadioButton.GroupName` is bound to a **per-row** `RadioGroup` string, since a shared group name would make every schedule share one selection. `DailyPrepMaxTasks` is disabled unless some row is `FillMyDay`. |
|
||||
| `ListSettingsModalViewModel` | Name, working dir, commit type, "manual list" flag, `VerifyCommand`, delete. Hosts the shared `AgentConfigEditorViewModel` as `Agent` (scope=List) — ⚠️ save delegates to `Agent.SaveAsync(verifyCommand)` because both land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other. |
|
||||
| `ListSettingsModalViewModel` | Name, working dir, commit type, "manual list" flag, `VerifyCommand`, delete. Hosts the shared `AgentConfigEditorViewModel` as `Agent` (scope=List) — ⚠️ save delegates to `Agent.SaveAsync(verifyCommand)` because both land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other. **Doubles as the folder editor:** `IsFolder` collapses the body to name + icon, and it's reached either via the create-only "create a folder instead" toggle (`CanToggleFolder`, set by `BeginLoad`) or via `BeginLoadFolder` on an existing folder. There is no list↔folder conversion. Creating a folder writes a `list_folders` row and **deletes the list row the create flow pre-created**, reporting it as `Deleted` so the caller does a full reload. |
|
||||
| `WeeklyReportModalViewModel` | Range pickers default "since last standup weekday → today", cached per range. |
|
||||
| `MergeHelperSelectionModalViewModel` | "Let Claude handle it" picker → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). |
|
||||
| `UsageMonitorModalViewModel` | Opened from the usage pill (shown **before** the data loads via `BeginLoad`); gauges are **dynamic** per `UsageSnapshotDto.Limits` row, and the 5h/7d ones carry three draggable stage markers (soft/hard/gate) via `UsageGaugeBar` + the pure `UsageThresholdDrag`, plus a colour-matched legend with a `NumericUpDown` per stage → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). The **analytics** part (models/tasks/cost) comes from the external TokenTracker export, not from us: without the CLI the modal shows a hint card with an install button (gated on Node ≥ 20) instead of the totals header. Gauges, gate and throttle are unaffected by it. The modal is **display + threshold drag only** — the PowerUse switch/window/handoff % it used to host moved to Settings → Execution (`GeneralSettingsTabViewModel`), and "Refresh now" is an icon in the titlebar via `ModalShell.HeaderActions` rather than a body row. |
|
||||
|
||||
@@ -297,6 +297,18 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource StatusQueuedBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- waiting → muted amber: a Queued task blocked on an unmet predecessor (a task_dependencies
|
||||
edge, or the planning chain's BlockedByTaskId) — distinct from queued (unblocked, next up
|
||||
to run) and from children (a parent waiting on its own subtasks). Mutually exclusive with
|
||||
.queued on the same chip (IsQueued/IsWaiting), so no declaration-order fight between them. -->
|
||||
<Style Selector="Border.chip.waiting">
|
||||
<Setter Property="Background" Value="#332F1A" />
|
||||
<Setter Property="BorderBrush" Value="#4D451A" />
|
||||
</Style>
|
||||
<Style Selector="Border.chip.waiting > TextBlock">
|
||||
<Setter Property="Foreground" Value="#C9A227" />
|
||||
</Style>
|
||||
|
||||
<!-- done → green (#6FA86B) -->
|
||||
<Style Selector="Border.chip.done">
|
||||
<Setter Property="Background" Value="{StaticResource DoneTintBrush}" />
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
// Two named types because DI resolves them by type, not by key.
|
||||
public sealed class InstallerLocator() : InstallArtifactLocator("uninstaller", "ClaudeDo.Installer.exe");
|
||||
// Two named types because DI resolves them by type, not by key. Not sealed: tests subclass
|
||||
// these to fake Find() without touching the real filesystem/registry.
|
||||
public class InstallerLocator() : InstallArtifactLocator("uninstaller", "ClaudeDo.Installer.exe");
|
||||
|
||||
public sealed class WorkerLocator() : InstallArtifactLocator("worker", "ClaudeDo.Worker.exe");
|
||||
public class WorkerLocator() : InstallArtifactLocator("worker", "ClaudeDo.Worker.exe");
|
||||
|
||||
/// <summary>
|
||||
/// Locates an executable inside a ClaudeDo install: walk up from the running
|
||||
@@ -14,7 +15,7 @@ public abstract class InstallArtifactLocator(string subdir, string exeName)
|
||||
{
|
||||
private const string InstallJson = "install.json";
|
||||
|
||||
public string? Find()
|
||||
public virtual string? Find()
|
||||
=> FindByWalkingUp(AppContext.BaseDirectory)
|
||||
?? (OperatingSystem.IsWindows() ? FindByRegistry() : null);
|
||||
|
||||
|
||||
@@ -52,13 +52,6 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// total.</summary>
|
||||
event Action<string, string, int, int>? OperationProgressEvent;
|
||||
|
||||
/// <summary>(taskId, phase, elapsedSeconds) — phase of an in-flight single-task merge
|
||||
/// ("merging" | "verifying"). Fires while the MergeTask call itself is still pending, so the
|
||||
/// waiting UI can show what it's blocked on; the verify phase re-fires every 30 s. A thin
|
||||
/// forwarder over <see cref="OperationProgressEvent"/> kept for existing consumers (elapsed
|
||||
/// seconds riding in the generic "current" slot).</summary>
|
||||
event Action<string, string, int>? MergeProgressEvent;
|
||||
|
||||
event Action<string, string>? PlanningMergeStartedEvent;
|
||||
event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||
/// <summary>(planningTaskId, subtaskId, conflictedFiles, externallyDriven). externallyDriven
|
||||
@@ -117,9 +110,11 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// and clears BlockedByTaskId in the same update) — call once per row (parent and each
|
||||
/// queued child) rather than cascading server-side.</summary>
|
||||
Task DequeueTaskAsync(string taskId);
|
||||
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch);
|
||||
/// <param name="autostash">Retry flag for status "dirty_target": stash the target working
|
||||
/// tree's uncommitted changes before merging and restore them afterwards (git --autostash).</param>
|
||||
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false);
|
||||
Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch);
|
||||
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
|
||||
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage, bool autostash = false);
|
||||
Task RejectReviewToQueueAsync(string taskId, string feedback);
|
||||
Task RejectReviewToIdleAsync(string taskId);
|
||||
Task CancelReviewAsync(string taskId);
|
||||
@@ -143,8 +138,9 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// <summary>Launch spec for an embedded ConPTY "merge helper" session that drives the given
|
||||
/// tasks to a merged/Done state. listId scopes the session (and cwd) to that list.
|
||||
/// handlerTaskId (from CreateMergeHelperTaskAsync) is rendered into the brief so the session
|
||||
/// can call handoff_list_handler on its own handler task.</summary>
|
||||
Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct = default);
|
||||
/// can call handoff_list_handler on its own handler task. model overrides the triage
|
||||
/// session's model (null = default).</summary>
|
||||
Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default);
|
||||
/// <summary>Creates the ClaudeDo task that owns a list-handler run (one per run, Idle/IsManual,
|
||||
/// never queued) so the ConPTY tile can be task-based instead of ad-hoc. Returns the new task id.</summary>
|
||||
Task<string> CreateMergeHelperTaskAsync(
|
||||
|
||||
@@ -162,15 +162,19 @@ public sealed class PtyTerminalSession : IDisposable
|
||||
/// writing the clipboard text raw, because that is what honours bracketed-paste mode — without
|
||||
/// it a multi-line paste is read line by line and submits on the first newline.
|
||||
/// </summary>
|
||||
public async Task PasteAsync()
|
||||
public async Task<bool> PasteAsync()
|
||||
{
|
||||
if (_control is not { } control || !IsRunning) return;
|
||||
if (_control is not { } control || !IsRunning) return false;
|
||||
try
|
||||
{
|
||||
if (s_terminalViewField?.GetValue(control) is TerminalView view)
|
||||
await view.PasteAsync();
|
||||
if (s_terminalViewField?.GetValue(control) is not TerminalView view) return false;
|
||||
await view.PasteAsync();
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception) { /* best-effort, same as SendLineAsync */ }
|
||||
}
|
||||
|
||||
// Long enough that the two writes can't be read as one chunk by the child's stdin reader.
|
||||
|
||||
@@ -69,5 +69,5 @@ public static class RepoScanner
|
||||
|
||||
// A real clone has .git as a directory. Worktrees and submodules have it as a file
|
||||
// ("gitdir: ...") — those are not standalone repos and would flood the list.
|
||||
private static bool IsRepo(string dir) => Directory.Exists(Path.Combine(dir, ".git"));
|
||||
public static bool IsRepo(string dir) => Directory.Exists(Path.Combine(dir, ".git"));
|
||||
}
|
||||
|
||||
@@ -67,7 +67,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public event Action<UsageSnapshotDto>? UsageUpdatedEvent;
|
||||
|
||||
public event Action<string, string, int, int>? OperationProgressEvent;
|
||||
public event Action<string, string, int>? MergeProgressEvent;
|
||||
|
||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||
@@ -189,11 +188,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
|
||||
_hub.On<string, string, int, int>("OperationProgress", (opKey, phase, current, total) =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
OperationProgressEvent?.Invoke(opKey, phase, current, total);
|
||||
MergeProgressEvent?.Invoke(opKey, phase, current);
|
||||
});
|
||||
Dispatcher.UIThread.Post(() => OperationProgressEvent?.Invoke(opKey, phase, current, total));
|
||||
});
|
||||
|
||||
_hub.On<string, string>("PlanningMergeStarted", (planningTaskId, targetBranch) =>
|
||||
@@ -358,10 +353,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await InvokeTimedAsync("ResetTask", () => _hub.InvokeAsync("ResetTask", taskId));
|
||||
}
|
||||
|
||||
public async Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage)
|
||||
public async Task<MergeResultDto> MergeTaskAsync(
|
||||
string taskId, string targetBranch, bool removeWorktree, string commitMessage, bool autostash = false)
|
||||
{
|
||||
return await InvokeTimedAsync<MergeResultDto>("MergeTask", () => _hub.InvokeAsync<MergeResultDto>(
|
||||
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage));
|
||||
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage, autostash));
|
||||
}
|
||||
|
||||
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
|
||||
@@ -558,10 +554,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public Task DequeueTaskAsync(string taskId)
|
||||
=> InvokeTimedAsync("DequeueTask", () => _hub.InvokeAsync("DequeueTask", taskId));
|
||||
|
||||
public async Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
|
||||
public async Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false)
|
||||
{
|
||||
LastApproveTarget = targetBranch;
|
||||
return await InvokeTimedAsync<MergeResultDto>("ApproveReview", () => _hub.InvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch));
|
||||
return await InvokeTimedAsync<MergeResultDto>("ApproveReview", () => _hub.InvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch, autostash));
|
||||
}
|
||||
|
||||
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)
|
||||
@@ -627,8 +623,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
||||
=> await InvokeTimedAsync<LaunchSpec>("GetAdHocLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct));
|
||||
|
||||
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct = default)
|
||||
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, handlerTaskId, ct));
|
||||
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
|
||||
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, handlerTaskId, model, ct));
|
||||
|
||||
public async Task<string> CreateMergeHelperTaskAsync(
|
||||
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
|
||||
|
||||
@@ -145,7 +145,7 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
||||
{
|
||||
// Any non-conflict outcome here means nothing was left mid-merge for this dialog
|
||||
// to resolve -- surface why instead of quietly closing with no explanation.
|
||||
if (start.Status is "blocked" or "untracked_collision")
|
||||
if (start.Status is "blocked" or "untracked_collision" or "dirty_target")
|
||||
Error = start.ErrorMessage;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using Iciclecreek.Terminal;
|
||||
|
||||
@@ -36,14 +37,29 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
|
||||
/// can show a spinner instead of an empty black pane.</summary>
|
||||
public bool IsStarting => !IsRunning && !HasExited && StartError is null;
|
||||
|
||||
/// <summary>True once the process has died without a <see cref="StartError"/> already covering it —
|
||||
/// i.e. every death that isn't a launch-time failure: a crash after running a while, a clean exit,
|
||||
/// or a quiet exit inside the startup grace window. Drives the host's "session ended" banner so a
|
||||
/// dead pane never looks like it's still running/thinking.</summary>
|
||||
public bool HasExitedAfterStartup => HasExited && StartError is null;
|
||||
|
||||
partial void OnIsRunningChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsStarting));
|
||||
if (value) _startedAtUtc = _utcNow();
|
||||
}
|
||||
|
||||
partial void OnHasExitedChanged(bool value) => OnPropertyChanged(nameof(IsStarting));
|
||||
partial void OnStartErrorChanged(string? value) => OnPropertyChanged(nameof(IsStarting));
|
||||
partial void OnHasExitedChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsStarting));
|
||||
OnPropertyChanged(nameof(HasExitedAfterStartup));
|
||||
}
|
||||
|
||||
partial void OnStartErrorChanged(string? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsStarting));
|
||||
OnPropertyChanged(nameof(HasExitedAfterStartup));
|
||||
}
|
||||
|
||||
public InteractiveTerminalViewModel() : this(() => DateTime.UtcNow)
|
||||
{
|
||||
@@ -96,6 +112,15 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Raised when a user-initiated paste or drop couldn't reach the session (write seam
|
||||
/// gone, no running process) — mirrors the island ViewModels' ErrorReported, forwarded by
|
||||
/// <see cref="ClaudeDo.Ui.ViewModels.MissionControl.ConPtyPaneViewModel"/> into the footer error strip.</summary>
|
||||
public event Action<string>? ErrorReported;
|
||||
|
||||
/// <summary>Called by the view after a failed <see cref="PasteAsync"/>/<see cref="SendTextAsync"/>
|
||||
/// so the failure surfaces through <see cref="ErrorReported"/> instead of vanishing silently.</summary>
|
||||
public void ReportSendFailed() => ErrorReported?.Invoke(Loc.T("missionControl.terminalSendFailed"));
|
||||
|
||||
/// <summary>Raised when the process died non-zero *after* clearing <see cref="StartupGraceWindow"/> —
|
||||
/// i.e. a session that ran, then broke. That used to be a silent dead tile with no signal at all;
|
||||
/// the worker turns it into a usage-limit auto-restart for list-handler sessions
|
||||
@@ -141,8 +166,9 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
|
||||
public Task<bool> SendTextAsync(string text) => _session.SendTextAsync(text);
|
||||
|
||||
/// <summary>Pastes the clipboard into the session (Ctrl+V) — see
|
||||
/// <see cref="PtyTerminalSession.PasteAsync"/>.</summary>
|
||||
public Task PasteAsync() => _session.PasteAsync();
|
||||
/// <see cref="PtyTerminalSession.PasteAsync"/>. False when there is no running process to
|
||||
/// paste into (or the write seam is gone).</summary>
|
||||
public Task<bool> PasteAsync() => _session.PasteAsync();
|
||||
|
||||
public void Kill() => _session.Kill();
|
||||
|
||||
|
||||
@@ -1262,25 +1262,34 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
using var op = ApproveOp.Begin(Loc.T("ops.merge.merging"));
|
||||
// Subscribed only for the duration of the call: the worker broadcasts to every
|
||||
// client, and a VM left on this event would keep reacting to merges for other tasks.
|
||||
void OnMergeProgress(string progressTaskId, string phase, int elapsedSeconds)
|
||||
{
|
||||
if (progressTaskId != taskId || phase != MergePhaseVerifying) return;
|
||||
ApproveOp.Report(Loc.T("ops.merge.verifying", FormatElapsed(elapsedSeconds)));
|
||||
}
|
||||
// Separate event: MergeProgressEvent forwards elapsed-seconds-only (verifying), but the
|
||||
// rebase phase needs the branch count too, which only rides the untrimmed OperationProgress.
|
||||
void OnOperationProgress(string opKey, string phase, int current, int total)
|
||||
{
|
||||
if (opKey != taskId || phase != MergePhaseRebasing) return;
|
||||
ApproveOp.Report(FormatRebasing(current, total));
|
||||
if (opKey != taskId) return;
|
||||
if (phase == MergePhaseVerifying)
|
||||
ApproveOp.Report(Loc.T("ops.merge.verifying", FormatElapsed(current)));
|
||||
else if (phase == MergePhaseRebasing)
|
||||
ApproveOp.Report(FormatRebasing(current, total));
|
||||
}
|
||||
_worker.MergeProgressEvent += OnMergeProgress;
|
||||
_worker.OperationProgressEvent += OnOperationProgress;
|
||||
try
|
||||
{
|
||||
var hasChildren = Subtasks.Count > 0 || ChildOutcomes.Count > 0;
|
||||
var result = await _worker.ApproveReviewAsync(taskId, Merge.SelectedMergeTarget ?? "");
|
||||
if (!hasChildren && result?.Status == "conflict")
|
||||
|
||||
// Nothing landed -- the target working dir has uncommitted changes (often another
|
||||
// session's WIP in the shared main checkout). Offer the one-shot stash retry; git
|
||||
// restores the changes itself once the merge is through.
|
||||
if (result?.Status == "dirty_target" && ConfirmAsync != null
|
||||
&& await ConfirmAsync(Loc.T("vm.detailsIsland.stashAndMergeConfirm")))
|
||||
{
|
||||
ApproveOp.Report(Loc.T("ops.merge.merging"));
|
||||
result = await _worker.ApproveReviewAsync(
|
||||
taskId, Merge.SelectedMergeTarget ?? "", autostash: true);
|
||||
}
|
||||
|
||||
if (result?.Status == "dirty_target" && ShowErrorAsync != null)
|
||||
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.dirtyTarget"));
|
||||
else if (!hasChildren && result?.Status == "conflict")
|
||||
await _merge.ResolveConflictAsync(taskId, Merge.SelectedMergeTarget ?? "");
|
||||
// The merge itself already landed; the verify command failed, so the task stayed
|
||||
// out of Done. Surface that instead of silently looking like nothing happened.
|
||||
@@ -1298,7 +1307,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
finally
|
||||
{
|
||||
_worker.MergeProgressEvent -= OnMergeProgress;
|
||||
_worker.OperationProgressEvent -= OnOperationProgress;
|
||||
}
|
||||
}
|
||||
@@ -1509,12 +1517,17 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
// Shared by TaskRowViewModel.UpdateFromEntity — this is the one parser for
|
||||
// `git diff --stat`'s summary line ("N insertions(+), M deletions(-)"). Anchored on the
|
||||
// literal "(+)"/"(-)" markers rather than the English words: git for Windows localizes
|
||||
// "insertion"/"deletion" to the OS language (e.g. German "Einfügung(en)"/"Löschung(en)"),
|
||||
// but always keeps the untranslated (+)/(-) suffix, so this reads correctly in any locale.
|
||||
internal static (int Additions, int Deletions) ParseDiffStat(string? stat)
|
||||
{
|
||||
if (string.IsNullOrEmpty(stat)) return (0, 0);
|
||||
int add = 0, del = 0;
|
||||
var m1 = System.Text.RegularExpressions.Regex.Match(stat, @"(\d+)\s+insertion");
|
||||
var m2 = System.Text.RegularExpressions.Regex.Match(stat, @"(\d+)\s+deletion");
|
||||
var m1 = System.Text.RegularExpressions.Regex.Match(stat, @"(\d+)[^,\n]*\(\+\)");
|
||||
var m2 = System.Text.RegularExpressions.Regex.Match(stat, @"(\d+)[^,\n]*\(-\)");
|
||||
if (m1.Success) int.TryParse(m1.Groups[1].Value, out add);
|
||||
if (m2.Success) int.TryParse(m2.Groups[1].Value, out del);
|
||||
return (add, del);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
/// <summary>
|
||||
/// A sidebar folder row. Not a <see cref="ListNavItemViewModel"/> and not a
|
||||
/// <see cref="ListKind"/> — a folder holds no tasks and is never selectable, so tapping it
|
||||
/// toggles <see cref="IsCollapsed"/> instead of changing the selection.
|
||||
/// </summary>
|
||||
public sealed partial class ListFolderNavItemViewModel : ViewModelBase
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
[ObservableProperty] private string _name = "";
|
||||
[ObservableProperty] private string _iconKey = "Folder";
|
||||
[ObservableProperty] private bool _isCollapsed;
|
||||
// Sum of the child lists' counts — a collapsed folder still shows what's inside it.
|
||||
[ObservableProperty] private int _count;
|
||||
// Set while a dragged list row hovers over the middle of this folder header (move-into-folder).
|
||||
[ObservableProperty] private bool _isDropTarget;
|
||||
// Insert-position hints for a drop next to the folder instead of into it. Drawn around the
|
||||
// whole folder block (header + children), not just the header.
|
||||
[ObservableProperty] private bool _dropHintAbove;
|
||||
[ObservableProperty] private bool _dropHintBelow;
|
||||
|
||||
public ObservableCollection<ListNavItemViewModel> Lists { get; } = new();
|
||||
}
|
||||
@@ -26,4 +26,6 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _isTaskDropTarget;
|
||||
// Settable (not init) so a list-settings icon change refreshes the row in place.
|
||||
[ObservableProperty] private string? _iconKey;
|
||||
// The sidebar folder this row sits in (ListEntity.FolderId); null = top level.
|
||||
[ObservableProperty] private string? _folderId;
|
||||
}
|
||||
|
||||
@@ -17,8 +17,9 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public enum ListKind { Smart, Virtual, User }
|
||||
|
||||
/// <summary>Confirmed handler run: the scope list and the ordered selected task ids.</summary>
|
||||
public sealed record MergeHelperRequest(string ListId, IReadOnlyList<string> TaskIds);
|
||||
/// <summary>Confirmed handler run: the scope list, the ordered selected task ids, and the model
|
||||
/// picked for the triage session (null = default; review-mode runs skip triage entirely).</summary>
|
||||
public sealed record MergeHelperRequest(string ListId, IReadOnlyList<string> TaskIds, string? TriageModel = null);
|
||||
|
||||
public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
@@ -26,6 +27,9 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
/// island show the notes editor instead of rows.</summary>
|
||||
public const string NotesListId = "smart:notes";
|
||||
|
||||
/// <summary>Icon key a folder falls back to when it has none of its own.</summary>
|
||||
public const string ListFolderIcon = "Folder";
|
||||
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly IServiceProvider? _services;
|
||||
private readonly IWorkerClient? _worker;
|
||||
@@ -111,7 +115,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
await vm.LoadAsync();
|
||||
var ids = await Dialogs.ShowMergeHelperSelectionAsync(vm);
|
||||
if (ids is { Count: > 0 })
|
||||
LetClaudeHandleRequested?.Invoke(new MergeHelperRequest(rawId, ids));
|
||||
LetClaudeHandleRequested?.Invoke(new MergeHelperRequest(rawId, ids, vm.SelectedModel));
|
||||
}
|
||||
|
||||
/// <summary>Raised once per list after the review-scoped selection dialog is confirmed;
|
||||
@@ -202,7 +206,22 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
public ObservableCollection<ListNavItemViewModel> Items { get; } = new();
|
||||
public ObservableCollection<ListNavItemViewModel> SmartLists { get; } = new();
|
||||
/// <summary>Every user list, flat, regardless of folder — the set the count refresh, the
|
||||
/// row refresh and the no-repo banner work over.</summary>
|
||||
public ObservableCollection<ListNavItemViewModel> UserLists { get; } = new();
|
||||
/// <summary>The sidebar's top level, in render order: folder rows
|
||||
/// (<see cref="ListFolderNavItemViewModel"/>) and folder-less lists
|
||||
/// (<see cref="ListNavItemViewModel"/>) interleaved, so a list can sit above a folder.
|
||||
/// This is what the view binds to; <see cref="UserLists"/> stays the flat superset.</summary>
|
||||
public ObservableCollection<object> TopLevel { get; } = new();
|
||||
|
||||
/// <summary>Sidebar folders in order — a projection of <see cref="TopLevel"/>, not a second
|
||||
/// collection to keep in sync.</summary>
|
||||
public IReadOnlyList<ListFolderNavItemViewModel> Folders =>
|
||||
TopLevel.OfType<ListFolderNavItemViewModel>().ToList();
|
||||
/// <summary>The user lists that sit outside any folder — likewise a projection.</summary>
|
||||
public IReadOnlyList<ListNavItemViewModel> RootLists =>
|
||||
TopLevel.OfType<ListNavItemViewModel>().ToList();
|
||||
|
||||
[ObservableProperty] private string _searchText = "";
|
||||
[ObservableProperty] private ListNavItemViewModel? _selectedList;
|
||||
@@ -279,6 +298,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
Items.Clear();
|
||||
SmartLists.Clear();
|
||||
UserLists.Clear();
|
||||
TopLevel.Clear();
|
||||
|
||||
var smart = new[]
|
||||
{
|
||||
@@ -299,6 +319,24 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
var ticketsConfigured = await IsTicketsConfiguredAsync();
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var lists = new ListRepository(ctx);
|
||||
|
||||
// Folders and top-level lists share one sort_order space, so both go into a single
|
||||
// ordered bucket and get merged by it — that's what interleaves them.
|
||||
var topLevel = new List<(int Sort, object Item)>();
|
||||
var foldersById = new Dictionary<string, ListFolderNavItemViewModel>();
|
||||
foreach (var f in await lists.GetFoldersAsync(ct))
|
||||
{
|
||||
var folder = new ListFolderNavItemViewModel
|
||||
{
|
||||
Id = f.Id,
|
||||
Name = f.Name,
|
||||
IconKey = f.Icon ?? ListFolderIcon,
|
||||
IsCollapsed = f.IsCollapsed,
|
||||
};
|
||||
foldersById[f.Id] = folder;
|
||||
topLevel.Add((f.SortOrder, folder));
|
||||
}
|
||||
|
||||
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
|
||||
foreach (var l in await lists.GetAllAsync(ct))
|
||||
{
|
||||
@@ -315,10 +353,20 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
IsManual = l.IsManual,
|
||||
FindingsTracked = l.FindingsTracked,
|
||||
HasTicketProject = ticketsConfigured && cfg?.TicketProjectId > 0,
|
||||
FolderId = l.FolderId,
|
||||
};
|
||||
Items.Add(item);
|
||||
UserLists.Add(item);
|
||||
if (item.FolderId is { } fid && foldersById.TryGetValue(fid, out var owner))
|
||||
owner.Lists.Add(item);
|
||||
else
|
||||
topLevel.Add((l.SortOrder, item));
|
||||
}
|
||||
|
||||
// OrderBy is stable, so legacy rows (folders and lists each numbered from 0
|
||||
// independently) keep the old look — folders first — until the first drag.
|
||||
foreach (var (_, item) in topLevel.OrderBy(t => t.Sort))
|
||||
TopLevel.Add(item);
|
||||
ok = true;
|
||||
}
|
||||
finally { OperationTiming.Shared.Record("db", "ListsIsland.LoadAsync", sw.Elapsed, ok); }
|
||||
@@ -365,6 +413,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
var filter = _filters.Resolve(item.Id);
|
||||
item.Count = filter is null ? 0 : all.Count(filter.ShouldCount);
|
||||
}
|
||||
|
||||
RecomputeFolderCounts();
|
||||
ok = true;
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
@@ -403,6 +453,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
};
|
||||
Items.Add(item);
|
||||
UserLists.Add(item);
|
||||
TopLevel.Add(item);
|
||||
SelectedList = item;
|
||||
RecomputeHasNoLinkedRepo();
|
||||
|
||||
@@ -410,7 +461,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
|
||||
vm.BeginLoad(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon);
|
||||
// canToggleFolder: only the create flow offers "make this a folder instead".
|
||||
vm.BeginLoad(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon, canToggleFolder: true);
|
||||
await Dialogs.ShowListSettingsAsync(vm);
|
||||
if (!vm.Saved && !vm.Deleted)
|
||||
{
|
||||
@@ -432,42 +484,186 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
r.DropHintAbove = false;
|
||||
r.DropHintBelow = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetDropHint(ListNavItemViewModel target, bool placeBelow)
|
||||
{
|
||||
foreach (var r in UserLists)
|
||||
foreach (var f in Folders)
|
||||
{
|
||||
var isTarget = ReferenceEquals(r, target);
|
||||
r.DropHintAbove = isTarget && !placeBelow;
|
||||
r.DropHintBelow = isTarget && placeBelow;
|
||||
f.IsDropTarget = false;
|
||||
f.DropHintAbove = false;
|
||||
f.DropHintBelow = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow)
|
||||
/// <summary>Marks the insert position for a drop next to <paramref name="target"/> — either a
|
||||
/// list row or a folder header (folders take the same two hint lines, drawn around the whole
|
||||
/// folder block).</summary>
|
||||
public void SetDropHint(object target, bool placeBelow)
|
||||
{
|
||||
ClearDropHints();
|
||||
switch (target)
|
||||
{
|
||||
case ListNavItemViewModel row:
|
||||
row.DropHintAbove = !placeBelow;
|
||||
row.DropHintBelow = placeBelow;
|
||||
break;
|
||||
case ListFolderNavItemViewModel folder:
|
||||
folder.DropHintAbove = !placeBelow;
|
||||
folder.DropHintBelow = placeBelow;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetFolderDropTarget(ListFolderNavItemViewModel? target)
|
||||
{
|
||||
ClearDropHints();
|
||||
if (target is not null) target.IsDropTarget = true;
|
||||
}
|
||||
|
||||
/// <summary>The collection a row is rendered in: its folder's <c>Lists</c>, or
|
||||
/// <see cref="TopLevel"/> when it has no folder. Non-generic because the top level is mixed.</summary>
|
||||
public System.Collections.IList ContainerOf(ListNavItemViewModel row) =>
|
||||
Folders.FirstOrDefault(f => f.Id == row.FolderId)?.Lists ?? (System.Collections.IList)TopLevel;
|
||||
|
||||
/// <summary>Kept for the list→list case; <see cref="DropAsync"/> covers all four combinations.</summary>
|
||||
public Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow) =>
|
||||
DropAsync(source, target, placeBelow);
|
||||
|
||||
/// <summary>
|
||||
/// Drops <paramref name="source"/> (a list row or a folder) at <paramref name="target"/>'s
|
||||
/// position. Since a drop position implies a container, dropping a list onto a row in a
|
||||
/// different folder (or next to anything at the top level) also *moves* it there — that's the
|
||||
/// only way to drag a list out of a folder. Folders live at the top level only, so a folder
|
||||
/// dropped onto a row inside a folder is ignored rather than nested.
|
||||
/// </summary>
|
||||
public async Task DropAsync(object source, object target, bool placeBelow)
|
||||
{
|
||||
if (source.Kind != ListKind.User || target.Kind != ListKind.User) return;
|
||||
if (ReferenceEquals(source, target)) return;
|
||||
if (target is ListNavItemViewModel { Kind: not ListKind.User }) return;
|
||||
|
||||
MoveWithinCollection(UserLists, source, target, placeBelow);
|
||||
if (source is ListFolderNavItemViewModel folder)
|
||||
{
|
||||
if (target is ListNavItemViewModel { FolderId: not null }) return;
|
||||
MoveWithinCollection(TopLevel, folder, target, placeBelow);
|
||||
await PersistOrderAsync(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (source is not ListNavItemViewModel row || row.Kind != ListKind.User) return;
|
||||
|
||||
// Next to a folder *header* means top level, not inside it — MoveToFolderAsync is the
|
||||
// drop-into-the-folder path.
|
||||
var targetFolderId = target is ListNavItemViewModel t ? t.FolderId : null;
|
||||
var folderChanged = row.FolderId != targetFolderId;
|
||||
if (folderChanged)
|
||||
{
|
||||
ContainerOf(row).Remove(row);
|
||||
row.FolderId = targetFolderId;
|
||||
ContainerOf(row).Add(row);
|
||||
}
|
||||
|
||||
MoveWithinCollection(ContainerOf(row), row, target, placeBelow);
|
||||
await PersistOrderAsync(folderChanged ? row : null);
|
||||
}
|
||||
|
||||
/// <summary>Appends a list to a folder (or to the top level when <paramref name="folder"/> is
|
||||
/// null) — the drop-onto-the-folder-header path, which also reaches collapsed and empty folders.</summary>
|
||||
public async Task MoveToFolderAsync(ListNavItemViewModel source, ListFolderNavItemViewModel? folder)
|
||||
{
|
||||
if (source.Kind != ListKind.User) return;
|
||||
if (source.FolderId == folder?.Id) return;
|
||||
|
||||
ContainerOf(source).Remove(source);
|
||||
source.FolderId = folder?.Id;
|
||||
ContainerOf(source).Add(source);
|
||||
await PersistOrderAsync(source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the sidebar's visual order back as a single global <c>sort_order</c> sequence
|
||||
/// (folders top to bottom, then the top-level lists) and, when a list changed folder, its new
|
||||
/// <c>folder_id</c>.
|
||||
/// </summary>
|
||||
private async Task PersistOrderAsync(ListNavItemViewModel? movedRow)
|
||||
{
|
||||
var ordered = new List<(string Id, bool IsFolder)>();
|
||||
foreach (var item in TopLevel)
|
||||
{
|
||||
switch (item)
|
||||
{
|
||||
case ListFolderNavItemViewModel f:
|
||||
ordered.Add((f.Id, true));
|
||||
ordered.AddRange(f.Lists.Select(l => (RawId(l.Id), false)));
|
||||
break;
|
||||
case ListNavItemViewModel l:
|
||||
ordered.Add((RawId(l.Id), false));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var orderedIds = UserLists.Select(i => i.Id["user:".Length..]).ToList();
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var ok = false;
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var lists = new ListRepository(ctx);
|
||||
await lists.ReorderAsync(orderedIds);
|
||||
if (movedRow is not null)
|
||||
await lists.SetListFolderAsync(RawId(movedRow.Id), movedRow.FolderId);
|
||||
await lists.ReorderSidebarAsync(ordered);
|
||||
ok = true;
|
||||
}
|
||||
finally { OperationTiming.Shared.Record("db", "ListsIsland.ReorderAsync", sw.Elapsed, ok); }
|
||||
|
||||
RecomputeFolderCounts();
|
||||
}
|
||||
|
||||
/// <summary>Context-menu counterpart to dragging a list out of a folder.</summary>
|
||||
[RelayCommand]
|
||||
private async Task RemoveFromFolderAsync(ListNavItemViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
await MoveToFolderAsync(row, null);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ToggleFolderAsync(ListFolderNavItemViewModel? folder)
|
||||
{
|
||||
if (folder is null) return;
|
||||
folder.IsCollapsed = !folder.IsCollapsed;
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
await new ListRepository(ctx).SetFolderCollapsedAsync(folder.Id, folder.IsCollapsed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The fold itself already happened in the UI; only its persistence failed.
|
||||
ErrorReported?.Invoke(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenFolderSettingsAsync(ListFolderNavItemViewModel? folder)
|
||||
{
|
||||
if (folder is null || Dialogs is null || _services is null) return;
|
||||
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
|
||||
vm.BeginLoadFolder(folder.Id, folder.Name, folder.IconKey);
|
||||
await Dialogs.ShowListSettingsAsync(vm);
|
||||
// Rename, re-icon and delete all change the folder header (and delete releases its lists
|
||||
// back to the top level) — one reload covers every outcome.
|
||||
if (vm.Saved || vm.Deleted) await LoadAsync();
|
||||
}
|
||||
|
||||
private void RecomputeFolderCounts()
|
||||
{
|
||||
foreach (var f in Folders) f.Count = f.Lists.Sum(l => l.Count);
|
||||
}
|
||||
|
||||
private static string RawId(string rowId) =>
|
||||
rowId.StartsWith("user:", StringComparison.Ordinal) ? rowId["user:".Length..] : rowId;
|
||||
|
||||
private static void MoveWithinCollection(
|
||||
ObservableCollection<ListNavItemViewModel> coll,
|
||||
ListNavItemViewModel source,
|
||||
ListNavItemViewModel target,
|
||||
System.Collections.IList coll,
|
||||
object source,
|
||||
object target,
|
||||
bool placeBelow)
|
||||
{
|
||||
var srcIdx = coll.IndexOf(source);
|
||||
@@ -480,7 +676,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
if (finalIdx >= coll.Count) finalIdx = coll.Count - 1;
|
||||
if (finalIdx == srcIdx) return;
|
||||
|
||||
coll.Move(srcIdx, finalIdx);
|
||||
coll.RemoveAt(srcIdx);
|
||||
coll.Insert(finalIdx, source);
|
||||
}
|
||||
|
||||
partial void OnSelectedListChanged(ListNavItemViewModel? value)
|
||||
@@ -570,6 +767,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
IsManual = entity.IsManual,
|
||||
FindingsTracked = entity.FindingsTracked,
|
||||
HasTicketProject = hasTicketProject,
|
||||
FolderId = entity.FolderId,
|
||||
};
|
||||
|
||||
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
|
||||
@@ -579,6 +777,22 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
UserLists.Insert(insertAt, item);
|
||||
Items.Insert(SmartLists.Count + insertAt, item);
|
||||
|
||||
// Inside a folder, the same DB-order position; at the top level just appended — the mixed
|
||||
// top level has no index that a per-folder DB order maps onto, and AddAsync numbers a new
|
||||
// list past everything anyway.
|
||||
if (Folders.FirstOrDefault(f => f.Id == item.FolderId) is { } folder)
|
||||
{
|
||||
var containerIdx = ordered
|
||||
.Where(l => l.FolderId == item.FolderId)
|
||||
.Select(l => l.Id)
|
||||
.ToList()
|
||||
.IndexOf(entity.Id);
|
||||
folder.Lists.Insert(
|
||||
containerIdx < 0 || containerIdx > folder.Lists.Count ? folder.Lists.Count : containerIdx,
|
||||
item);
|
||||
}
|
||||
else TopLevel.Add(item);
|
||||
RecomputeHasNoLinkedRepo();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,11 +112,31 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
[NotifyPropertyChangedFor(nameof(IsQueued))]
|
||||
[NotifyPropertyChangedFor(nameof(IsWaiting))]
|
||||
[NotifyPropertyChangedFor(nameof(StatusChipClass))]
|
||||
[NotifyPropertyChangedFor(nameof(StatusLabel))]
|
||||
[NotifyPropertyChangedFor(nameof(StatusChipTooltip))]
|
||||
[NotifyPropertyChangedFor(nameof(CanRemoveFromQueue))]
|
||||
private string? _blockedByTaskId;
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(DependsOnTaskId))]
|
||||
private IReadOnlyList<string> _dependsOnTaskIds = [];
|
||||
// Extrinsic — assigned by TasksIslandViewModel.ApplyDependencyWaitState, which alone has the
|
||||
// cross-row graph (plus any cross-list predecessor lookup) needed to tell an unmet
|
||||
// task_dependencies edge from a satisfied one. True only for the task_dependencies case;
|
||||
// IsWaiting also ORs in the older BlockedByTaskId planning-chain link.
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsQueued))]
|
||||
[NotifyPropertyChangedFor(nameof(IsWaiting))]
|
||||
[NotifyPropertyChangedFor(nameof(StatusChipClass))]
|
||||
[NotifyPropertyChangedFor(nameof(StatusLabel))]
|
||||
[NotifyPropertyChangedFor(nameof(StatusChipTooltip))]
|
||||
[NotifyPropertyChangedFor(nameof(CanRemoveFromQueue))]
|
||||
private bool _isBlockedByDependency;
|
||||
// The predecessor(s) this row is waiting on, rendered ("#42" or "#42, #57"), or null when not
|
||||
// waiting. Prefers BlockedByTaskId's single predecessor (the planning chain "parent wins" the
|
||||
// same way it does for the chain rail), else lists every unmet task_dependencies edge.
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(StatusChipTooltip))]
|
||||
private string? _dependencyWaitLabel;
|
||||
|
||||
// The single predecessor, or null when the task has none or several. The chain rail can only
|
||||
// draw a line, so a task waiting on n predecessors gets a ChainAfterLabel instead — see
|
||||
@@ -275,8 +295,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
// task). A live ConPTY session is that same shape but not parked — it reads "Interactive".
|
||||
public bool IsParked => Status == TaskStatus.Idle && WorktreeState == ClaudeDo.Data.Models.WorktreeState.Active
|
||||
&& !HasInteractiveSession;
|
||||
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId) && !IsBlockedByDependency;
|
||||
public bool IsWaiting => Status == TaskStatus.Queued && (!string.IsNullOrEmpty(BlockedByTaskId) || IsBlockedByDependency);
|
||||
public bool CanRemoveFromQueue => IsQueued || IsWaiting || HasQueuedSubtasks;
|
||||
// "Send to queue" is the single queue entry. On a finalized planning parent it queues the
|
||||
// plan (children) via CanQueuePlan; an Active (not-yet-finalized) planning parent is hidden —
|
||||
@@ -412,9 +432,12 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
: FailureReasonLabel;
|
||||
|
||||
// Drives the status chip tooltip: an open interactive session takes priority (it's tappable),
|
||||
// otherwise a Failed task's reason, otherwise no tooltip.
|
||||
// then the predecessor(s) a waiting task is blocked on, then a Failed task's reason,
|
||||
// otherwise no tooltip.
|
||||
public string? StatusChipTooltip
|
||||
=> HasInteractiveSession ? Loc.T("tasks.interactiveChipTip") : FailureReasonTooltip;
|
||||
=> HasInteractiveSession ? Loc.T("tasks.interactiveChipTip")
|
||||
: IsWaiting && DependencyWaitLabel is not null ? Loc.T("tasks.waitingOnTip", DependencyWaitLabel)
|
||||
: FailureReasonTooltip;
|
||||
|
||||
public string DiffAdditionsText => $"+{DiffAdditions}";
|
||||
public string DiffDeletionsText => $"−{DiffDeletions}";
|
||||
@@ -422,7 +445,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
|
||||
public string StatusLabel
|
||||
=> HasInteractiveSession ? Loc.T("vm.taskStatus.interactive")
|
||||
: IsParked ? Loc.T("vm.taskStatus.parked") : Status switch
|
||||
: IsParked ? Loc.T("vm.taskStatus.parked")
|
||||
: IsWaiting ? Loc.T("vm.taskStatus.waiting") : Status switch
|
||||
{
|
||||
TaskStatus.Idle => Loc.T("vm.taskStatus.idle"),
|
||||
TaskStatus.Queued => Loc.T("vm.taskStatus.queued"),
|
||||
@@ -444,7 +468,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
|
||||
public string StatusChipClass => HasInteractiveSession
|
||||
? "interactive"
|
||||
: (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId)) switch
|
||||
: (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId) || IsBlockedByDependency) switch
|
||||
{
|
||||
(TaskStatus.Running, _) => "running",
|
||||
(TaskStatus.WaitingForReview, _) => "review",
|
||||
@@ -476,7 +500,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
// Any entity-backed refresh reflects a settled state, so the transient creation-phase
|
||||
// banner (set from the OperationProgress broadcast) has nothing left to announce.
|
||||
CreationPhase = null;
|
||||
var (add, del) = ParseDiffStat(t.Worktree?.DiffStat);
|
||||
var (add, del) = DetailsIslandViewModel.ParseDiffStat(t.Worktree?.DiffStat);
|
||||
Number = t.Number;
|
||||
Title = t.Title;
|
||||
ListName = t.List?.Name ?? "";
|
||||
@@ -505,18 +529,4 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
FailureTurnsUsed = t.FailureTurnsUsed;
|
||||
FailureMaxTurns = t.FailureMaxTurns;
|
||||
}
|
||||
|
||||
// Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions".
|
||||
private static (int add, int del) ParseDiffStat(string? s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) return (0, 0);
|
||||
int add = 0, del = 0;
|
||||
var parts = s.Split(new[] { ' ', ',', '\t' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var p in parts)
|
||||
{
|
||||
if (p.Length > 1 && p[0] == '+' && int.TryParse(p.AsSpan(1), out var a)) add = a;
|
||||
else if (p.Length > 1 && (p[0] == '-' || p[0] == '\u2212') && int.TryParse(p.AsSpan(1), out var d)) del = d;
|
||||
}
|
||||
return (add, del);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
// Two events (TaskUpdated + WorktreeUpdated) drive the same delta refresh, so two reads for
|
||||
// one task can be in flight at once. Only the newest may write to the row.
|
||||
private readonly Dictionary<string, long> _deltaSeq = new();
|
||||
// Number/Title/Status for a predecessor id that isn't loaded into Items (a different list, My
|
||||
// Day, or the completed group) -- populated by RefreshExternalDependencyCacheAsync so
|
||||
// ApplyChainGrouping/ApplyDependencyWaitState can still render "after #N" and the blocked
|
||||
// chip for a cross-list predecessor instead of silently dropping it. Refreshed, never evicted:
|
||||
// stale entries just get overwritten on the next load/delta that references the same id.
|
||||
private readonly Dictionary<string, (int Number, string Title, TaskStatus Status)> _externalDependencyInfo = new();
|
||||
private long _deltaCounter;
|
||||
// Phase 3 reconcile tick — see the block at the end of this class.
|
||||
private readonly System.Timers.Timer _reconcileTimer = new(4_000);
|
||||
@@ -363,6 +369,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
r.ParentTaskId == parent.Id && (r.IsQueued || r.IsWaiting));
|
||||
}
|
||||
|
||||
// entity is not null here whenever `existing` is still reachable (see the branching
|
||||
// above: an early return covers every entity-null-but-existing case except delete, which
|
||||
// needs no lookup refresh).
|
||||
if (entity is not null && existing is not null)
|
||||
await RefreshExternalDependencyCacheAsync(db, DependencyLookupIds([existing]));
|
||||
|
||||
Regroup();
|
||||
UpdateSubtitle();
|
||||
}
|
||||
@@ -548,6 +560,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
r.ParentFinalized = !string.IsNullOrEmpty(r.ParentTaskId)
|
||||
&& finalizedParents.Contains(r.ParentTaskId!);
|
||||
|
||||
await RefreshExternalDependencyCacheAsync(db, DependencyLookupIds(Items), ct);
|
||||
|
||||
Regroup();
|
||||
UpdateSubtitle();
|
||||
ok = true;
|
||||
@@ -556,6 +570,32 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
finally { OperationTiming.Shared.Record("db", "TasksIsland.LoadForListAsync", sw.Elapsed, ok); }
|
||||
}
|
||||
|
||||
// Every predecessor id a row in `items` could need labelled/checked: its task_dependencies
|
||||
// edges plus the planning chain's BlockedByTaskId (both resolved the same way in
|
||||
// ApplyDependencyWaitState/BuildAfterLabel).
|
||||
private static IEnumerable<string> DependencyLookupIds(IEnumerable<TaskRowViewModel> items) =>
|
||||
items.SelectMany(r => string.IsNullOrEmpty(r.BlockedByTaskId)
|
||||
? r.DependsOnTaskIds
|
||||
: r.DependsOnTaskIds.Append(r.BlockedByTaskId));
|
||||
|
||||
// Resolves predecessor ids that aren't loaded into Items (a different list, My Day, or the
|
||||
// completed group) so cross-list dependency display doesn't silently drop them. Cheap: reuses
|
||||
// the caller's already-open context, one query for however many ids are actually missing.
|
||||
private async Task RefreshExternalDependencyCacheAsync(
|
||||
ClaudeDoDbContext db, IEnumerable<string> ids, CancellationToken ct = default)
|
||||
{
|
||||
var known = Items.Select(r => r.Id).ToHashSet();
|
||||
var missing = ids.Where(id => !string.IsNullOrEmpty(id) && !known.Contains(id)).Distinct().ToList();
|
||||
if (missing.Count == 0) return;
|
||||
|
||||
var rows = await db.Tasks
|
||||
.Where(t => missing.Contains(t.Id))
|
||||
.Select(t => new { t.Id, t.Number, t.Title, t.Status })
|
||||
.ToListAsync(ct);
|
||||
foreach (var r in rows)
|
||||
_externalDependencyInfo[r.Id] = (r.Number, r.Title, r.Status);
|
||||
}
|
||||
|
||||
internal void Regroup()
|
||||
{
|
||||
// Collapse parents that have children by default, so subtasks stay tucked away until
|
||||
@@ -691,13 +731,66 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
// with a label rather than an orphaned rail. Whether the head is resolvable at all
|
||||
// still uses the whole-Items graph (rowsById), independent of section.
|
||||
var rowsById = Items.ToDictionary(r => r.Id);
|
||||
overdue = ApplyChainGrouping(overdue, rowsById);
|
||||
open = ApplyChainGrouping(open, rowsById);
|
||||
completed = ApplyChainGrouping(completed, rowsById);
|
||||
|
||||
// Blocked-by-dependency is independent of section (a blocked row can be Overdue or Open
|
||||
// alike) and independent of chain membership, so it's computed once over all of Items
|
||||
// rather than per section.
|
||||
foreach (var r in Items)
|
||||
ApplyDependencyWaitState(r, rowsById, _externalDependencyInfo);
|
||||
|
||||
overdue = ApplyChainGrouping(overdue, rowsById, _externalDependencyInfo);
|
||||
open = ApplyChainGrouping(open, rowsById, _externalDependencyInfo);
|
||||
completed = ApplyChainGrouping(completed, rowsById, _externalDependencyInfo);
|
||||
|
||||
return (overdue, open, completed);
|
||||
}
|
||||
|
||||
// Assigns IsBlockedByDependency/DependencyWaitLabel for one row. BlockedByTaskId (the planning
|
||||
// chain's single link) wins the label the same way a planning child's parent indent wins over
|
||||
// chain-rail membership -- "parent wins" is the existing precedent (ApplyChainGrouping below).
|
||||
private static void ApplyDependencyWaitState(
|
||||
TaskRowViewModel row, Dictionary<string, TaskRowViewModel> rowsById,
|
||||
IReadOnlyDictionary<string, (int Number, string Title, TaskStatus Status)> external)
|
||||
{
|
||||
if (row.Status != TaskStatus.Queued)
|
||||
{
|
||||
row.IsBlockedByDependency = false;
|
||||
row.DependencyWaitLabel = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var unmet = row.DependsOnTaskIds.Where(id => IsUnmet(id, rowsById, external)).ToList();
|
||||
row.IsBlockedByDependency = unmet.Count > 0;
|
||||
|
||||
var waitIds = !string.IsNullOrEmpty(row.BlockedByTaskId) ? [row.BlockedByTaskId] : unmet;
|
||||
row.DependencyWaitLabel = waitIds.Count == 0 ? null
|
||||
: string.Join(", ", waitIds.Select(id => ResolveLabel(id, rowsById, external)).Where(s => s is not null));
|
||||
}
|
||||
|
||||
// A predecessor missing from both rowsById and the external cache counts as unmet -- mirrors
|
||||
// QueuePicker/Worker's TaskBlocking.Unmet: an edge pointing at an unresolved/vanished task
|
||||
// keeps blocking rather than being silently treated as satisfied.
|
||||
private static bool IsUnmet(
|
||||
string id, Dictionary<string, TaskRowViewModel> rowsById,
|
||||
IReadOnlyDictionary<string, (int Number, string Title, TaskStatus Status)> external)
|
||||
{
|
||||
if (rowsById.TryGetValue(id, out var p)) return p.Status != TaskStatus.Done;
|
||||
if (external.TryGetValue(id, out var e)) return e.Status != TaskStatus.Done;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Resolves a predecessor id to its display label, checking the in-view rows first and falling
|
||||
// back to the cross-list cache (RefreshExternalDependencyCacheAsync) -- null only when the
|
||||
// predecessor is genuinely unresolvable anywhere (e.g. a deleted task).
|
||||
private static string? ResolveLabel(
|
||||
string id, Dictionary<string, TaskRowViewModel> rowsById,
|
||||
IReadOnlyDictionary<string, (int Number, string Title, TaskStatus Status)> external)
|
||||
{
|
||||
if (rowsById.TryGetValue(id, out var p)) return p.Number > 0 ? $"#{p.Number}" : p.Title;
|
||||
if (external.TryGetValue(id, out var e)) return e.Number > 0 ? $"#{e.Number}" : e.Title;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cycles are rejected by TaskStateService.SetDependsOnAsync, so this should always
|
||||
// terminate quickly — capped anyway against a corrupt/legacy row forming a loop.
|
||||
private const int MaxChainWalkDepth = 64;
|
||||
@@ -724,14 +817,15 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
// Content only — "after " + localization is Slice 2's concern (the view), which is why this
|
||||
// holds just the identifying fragment rather than an assembled sentence. Names every
|
||||
// predecessor it can resolve: a task can wait on n of them, and hiding all but the first
|
||||
// would misreport what the row is actually waiting for.
|
||||
private static string? BuildAfterLabel(TaskRowViewModel row, Dictionary<string, TaskRowViewModel> rowsById)
|
||||
// predecessor it can resolve (in-view first, then the cross-list external cache): a task can
|
||||
// wait on n of them, and hiding all but the first would misreport what the row is waiting for.
|
||||
private static string? BuildAfterLabel(
|
||||
TaskRowViewModel row, Dictionary<string, TaskRowViewModel> rowsById,
|
||||
IReadOnlyDictionary<string, (int Number, string Title, TaskStatus Status)> external)
|
||||
{
|
||||
var parts = row.DependsOnTaskIds
|
||||
.Select(id => rowsById.TryGetValue(id, out var p) ? p : null)
|
||||
.Select(id => ResolveLabel(id, rowsById, external))
|
||||
.Where(p => p is not null)
|
||||
.Select(p => p!.Number > 0 ? $"#{p.Number}" : p.Title)
|
||||
.ToList();
|
||||
return parts.Count == 0 ? null : string.Join(", ", parts);
|
||||
}
|
||||
@@ -741,7 +835,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
// (ascending ChainStep), regardless of their SortOrder-derived position — mirroring how
|
||||
// planning children already sit right after their parent regardless of Items order.
|
||||
private static List<TaskRowViewModel> ApplyChainGrouping(
|
||||
List<TaskRowViewModel> section, Dictionary<string, TaskRowViewModel> rowsById)
|
||||
List<TaskRowViewModel> section, Dictionary<string, TaskRowViewModel> rowsById,
|
||||
IReadOnlyDictionary<string, (int Number, string Title, TaskStatus Status)> external)
|
||||
{
|
||||
var sectionIds = section.Select(r => r.Id).ToHashSet();
|
||||
var headIdOf = new Dictionary<TaskRowViewModel, string>();
|
||||
@@ -763,7 +858,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
r.ShowAsChainMember = false;
|
||||
r.ChainStep = null;
|
||||
r.ChainAfterLabel = BuildAfterLabel(r, rowsById);
|
||||
r.ChainAfterLabel = BuildAfterLabel(r, rowsById, external);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -773,7 +868,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
r.ShowAsChainMember = false;
|
||||
r.ChainStep = null;
|
||||
r.ChainAfterLabel = BuildAfterLabel(r, rowsById);
|
||||
r.ChainAfterLabel = BuildAfterLabel(r, rowsById, external);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -790,7 +885,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
r.ShowAsChainMember = false;
|
||||
r.ChainStep = null;
|
||||
r.ChainAfterLabel = BuildAfterLabel(r, rowsById);
|
||||
r.ChainAfterLabel = BuildAfterLabel(r, rowsById, external);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1739,7 +1834,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
// reflects any of that after a Regroup — without this the tick would heal the row's data
|
||||
// while leaving a completed task sitting in the Open section under a stale count. Gated
|
||||
// on a real change so an idle tick stays free.
|
||||
if (groupingChanged) Regroup();
|
||||
if (groupingChanged)
|
||||
{
|
||||
await using var db2 = await _dbFactory.CreateDbContextAsync(ct);
|
||||
await RefreshExternalDependencyCacheAsync(db2, DependencyLookupIds(Items), ct);
|
||||
Regroup();
|
||||
}
|
||||
}
|
||||
|
||||
// The slice of a row that Regroup reads: which section it lands in, where it sits inside it,
|
||||
|
||||
@@ -370,7 +370,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
Lists.LetClaudeHandleRequested += req =>
|
||||
{
|
||||
OpenMissionControl();
|
||||
_ = MissionControl.OpenMergeHelperConPtySessionAsync(req.ListId, req.TaskIds);
|
||||
_ = MissionControl.OpenMergeHelperConPtySessionAsync(req.ListId, req.TaskIds, req.TriageModel);
|
||||
};
|
||||
Lists.MergeReviewsRequested += req =>
|
||||
{
|
||||
@@ -631,20 +631,25 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
await _updateCheck.CheckNowAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[ObservableProperty] private string? _restartWorkerStatus;
|
||||
// How long to wait after launching the new worker process before deciding it survived
|
||||
// (e.g. a port conflict kills it almost immediately — see #292 for the worker-side fix).
|
||||
private const int RestartSurvivalCheckMs = 2000;
|
||||
|
||||
// Test seam: overrides the real kill-and-relaunch body, so tests can assert the
|
||||
// success/failure footer routing without spawning a real worker process.
|
||||
internal Func<Task>? RestartWorkerOperation { get; set; }
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RestartWorkerAsync()
|
||||
{
|
||||
RestartWorkerStatus = Loc.T("vm.shell.restartingWorker");
|
||||
try
|
||||
{
|
||||
await Task.Run(RestartWorkerService);
|
||||
await FlashRestartStatusAsync("Worker restarted.");
|
||||
await (RestartWorkerOperation ?? (() => Task.Run(RestartWorkerService)))();
|
||||
FlashFooterSuccess("Worker restarted.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await FlashRestartStatusAsync($"Restart failed: {ex.Message}");
|
||||
FlashFooterError($"Restart failed: {ex.Message} — check the worker log.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,8 +658,17 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
var exe = _workerLocator.Find();
|
||||
if (exe is null) throw new InvalidOperationException("Worker executable not found.");
|
||||
|
||||
// Only kill the worker belonging to THIS installation — not any other
|
||||
// ClaudeDo.Worker on the machine (e.g. a second install).
|
||||
KillMatchingWorkerProcesses(exe);
|
||||
|
||||
var started = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true });
|
||||
if (started is not null && started.WaitForExit(RestartSurvivalCheckMs))
|
||||
throw new InvalidOperationException($"worker process exited immediately (code {started.ExitCode})");
|
||||
}
|
||||
|
||||
// Only kill the worker belonging to THIS installation — not any other
|
||||
// ClaudeDo.Worker on the machine (e.g. a second install).
|
||||
private static void KillMatchingWorkerProcesses(string exe)
|
||||
{
|
||||
var exeFull = System.IO.Path.GetFullPath(exe);
|
||||
foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker"))
|
||||
{
|
||||
@@ -670,16 +684,121 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
catch { /* may have exited or be inaccessible */ }
|
||||
finally { p.Dispose(); }
|
||||
}
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true });
|
||||
}
|
||||
|
||||
private async Task FlashRestartStatusAsync(string text)
|
||||
// Set by MainWindow: force-closes the window without re-triggering its own close
|
||||
// confirmation (the shutdown dialog above already confirmed).
|
||||
public Action? CloseWindow { get; set; }
|
||||
|
||||
// Test seam: overrides the real kill-worker + close-window body.
|
||||
internal Action? ShutdownOperation { get; set; }
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ShutdownAsync()
|
||||
{
|
||||
RestartWorkerStatus = text;
|
||||
await Task.Delay(3000);
|
||||
if (RestartWorkerStatus == text) RestartWorkerStatus = null;
|
||||
if (Dialogs is null) return;
|
||||
var confirmed = await Dialogs.ConfirmAsync(Loc.T("vm.shell.shutdownConfirm"));
|
||||
if (!confirmed) return;
|
||||
(ShutdownOperation ?? ShutdownWorkerAndCloseApp)();
|
||||
}
|
||||
|
||||
private void ShutdownWorkerAndCloseApp()
|
||||
{
|
||||
var exe = _workerLocator.Find();
|
||||
if (exe is not null) KillMatchingWorkerProcesses(exe);
|
||||
CloseWindow?.Invoke();
|
||||
}
|
||||
|
||||
private bool _updatingClaudeCli;
|
||||
|
||||
private bool CanUpdateClaudeCli() => !_updatingClaudeCli;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanUpdateClaudeCli))]
|
||||
private async Task UpdateClaudeCliAsync()
|
||||
{
|
||||
_updatingClaudeCli = true;
|
||||
UpdateClaudeCliCommand.NotifyCanExecuteChanged();
|
||||
try
|
||||
{
|
||||
var dto = Worker is null ? null : await Worker.GetClaudeBinAsync();
|
||||
if (dto is null)
|
||||
{
|
||||
FlashFooterError("Update failed: worker offline.");
|
||||
return;
|
||||
}
|
||||
|
||||
var (success, message) = await RunClaudeUpdateAsync(dto);
|
||||
if (success) FlashFooterSuccess(message);
|
||||
else FlashFooterError(message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_updatingClaudeCli = false;
|
||||
UpdateClaudeCliCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(bool Success, string Message)> RunClaudeUpdateAsync(ClaudeBinDto dto)
|
||||
{
|
||||
var resolved = ClaudeDo.Data.Environment.ExecutableResolver.Resolve(dto.Value);
|
||||
if (resolved is null) return (false, $"Update failed: '{dto.Value}' not found.");
|
||||
|
||||
var psi = resolved.IsShim
|
||||
? BuildShimUpdatePsi(resolved.Path)
|
||||
: new System.Diagnostics.ProcessStartInfo { FileName = resolved.Path, ArgumentList = { "update" } };
|
||||
psi.UseShellExecute = false;
|
||||
psi.RedirectStandardOutput = true;
|
||||
psi.RedirectStandardError = true;
|
||||
psi.CreateNoWindow = true;
|
||||
|
||||
using var process = new System.Diagnostics.Process { StartInfo = psi };
|
||||
var stdout = new System.Text.StringBuilder();
|
||||
var stderr = new System.Text.StringBuilder();
|
||||
process.OutputDataReceived += (_, e) => { if (e.Data is not null) stdout.AppendLine(e.Data); };
|
||||
process.ErrorDataReceived += (_, e) => { if (e.Data is not null) stderr.AppendLine(e.Data); };
|
||||
|
||||
try { process.Start(); }
|
||||
catch (Exception ex) { return (false, $"Update failed: {ex.Message}"); }
|
||||
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
|
||||
var timedOut = false;
|
||||
// On timeout: kill the tree, which unblocks WaitForExitAsync below and lets the
|
||||
// async output readers drain/complete naturally (mirrors VerifyCommandRunner).
|
||||
await using var ctr = cts.Token.Register(() =>
|
||||
{
|
||||
timedOut = true;
|
||||
try { process.Kill(entireProcessTree: true); } catch { /* already exited */ }
|
||||
});
|
||||
|
||||
await process.WaitForExitAsync(CancellationToken.None);
|
||||
|
||||
return timedOut
|
||||
? (false, "Update failed: timed out after 5 minutes.")
|
||||
: SummarizeClaudeUpdateOutcome(process.ExitCode, stdout.ToString(), stderr.ToString());
|
||||
}
|
||||
|
||||
private static System.Diagnostics.ProcessStartInfo BuildShimUpdatePsi(string shimPath)
|
||||
{
|
||||
var shim = ClaudeDo.Data.Environment.ExecutableResolver.BuildShimStartInfo(shimPath, new[] { "update" });
|
||||
return new System.Diagnostics.ProcessStartInfo { FileName = shim.FileName, Arguments = shim.Arguments };
|
||||
}
|
||||
|
||||
// Pure so a test can drive it with fake process output instead of spawning a real `claude`.
|
||||
internal static (bool Success, string Message) SummarizeClaudeUpdateOutcome(int exitCode, string stdout, string stderr)
|
||||
{
|
||||
if (exitCode == 0)
|
||||
return (true, LastNonEmptyLine(stdout) ?? "Claude CLI is already up to date.");
|
||||
|
||||
var detail = LastNonEmptyLine(stderr) ?? LastNonEmptyLine(stdout) ?? "";
|
||||
return (false, $"Update failed (exit {exitCode}): {detail}");
|
||||
}
|
||||
|
||||
private static string? LastNonEmptyLine(string text) =>
|
||||
text.Split('\n').Select(l => l.Trim()).LastOrDefault(l => l.Length > 0);
|
||||
|
||||
[RelayCommand]
|
||||
private void DismissBanner()
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.MissionControl;
|
||||
@@ -88,14 +89,18 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
terminal.PropertyChanged += OnTerminalPropertyChanged;
|
||||
terminal.ExitedAfterStartup += OnTerminalExitedAfterStartup;
|
||||
terminal.ErrorReported += OnTerminalErrorReported;
|
||||
}
|
||||
|
||||
private void Unsubscribe(InteractiveTerminalViewModel terminal)
|
||||
{
|
||||
terminal.PropertyChanged -= OnTerminalPropertyChanged;
|
||||
terminal.ExitedAfterStartup -= OnTerminalExitedAfterStartup;
|
||||
terminal.ErrorReported -= OnTerminalErrorReported;
|
||||
}
|
||||
|
||||
private void OnTerminalErrorReported(string message) => ErrorReported?.Invoke(message);
|
||||
|
||||
private void OnTerminalExitedAfterStartup(int exitCode)
|
||||
{
|
||||
if (TaskId is { } id) SessionDiedAfterStartup?.Invoke(id, exitCode);
|
||||
@@ -137,8 +142,18 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
SubmitForReviewCommand.NotifyCanExecuteChanged();
|
||||
RetryCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
if (e.PropertyName is nameof(InteractiveTerminalViewModel.HasExitedAfterStartup)
|
||||
or nameof(InteractiveTerminalViewModel.ExitCode))
|
||||
{
|
||||
OnPropertyChanged(nameof(ExitedBannerText));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>"Session ended (code N)" text for <see cref="InteractiveTerminalViewModel.HasExitedAfterStartup"/> —
|
||||
/// computed here (rather than formatted in XAML) since <c>loc:Tr</c> has no binding-arg support.</summary>
|
||||
public string ExitedBannerText => Loc.T("missionControl.conptyExited", Terminal.ExitCode ?? 0);
|
||||
|
||||
/// <summary>Types a message into this pane's live session and submits it (the worker's
|
||||
/// pre-usage-limit warning). False when the session isn't running.</summary>
|
||||
public System.Threading.Tasks.Task<bool> SendLineAsync(string text) => Terminal.SendLineAsync(text);
|
||||
@@ -167,9 +182,10 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
|
||||
partial void OnIsAbandonPendingChanged(bool value) => AbandonRunCommand.NotifyCanExecuteChanged();
|
||||
|
||||
// A launch failure permanently occupies the TaskId dedupe slot unless the user can retry —
|
||||
// re-opening the same task would otherwise just re-focus a dead tile.
|
||||
private bool CanRetry() => Terminal.HasExited && Terminal.StartError is not null;
|
||||
// A dead session permanently occupies the TaskId dedupe slot unless the user can retry — whether
|
||||
// it died at launch (StartError) or after (HasExitedAfterStartup), the same descriptor factory
|
||||
// can always relaunch it, so any HasExited state is retryable.
|
||||
private bool CanRetry() => Terminal.HasExited;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanRetry))]
|
||||
private void Retry()
|
||||
|
||||
@@ -331,10 +331,10 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
// creates one new ClaudeDo task per run to own the session (title/diff/result), deduped by
|
||||
// TaskId like OpenConPtySessionAsync. The handler still merges the tasks it handles itself
|
||||
// (no worktree of its own) — see TaskEntity.HandlerBaseCommit/HandlerHeadCommit.
|
||||
public System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList<string> taskIds)
|
||||
public System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList<string> taskIds, string? triageModel = null)
|
||||
=> OpenListHandlerConPtySessionAsync(listId, taskIds,
|
||||
"missionControl.mergeHelperTitle", "missionControl.mergeHelperTaskTitle",
|
||||
taskId => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId, taskId));
|
||||
taskId => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId, taskId, triageModel));
|
||||
|
||||
// Merge-only list-handler session over a list's WaitingForReview tasks (the review list's
|
||||
// "Let Claude merge the reviews"). Same host-task shape as the full run, but the session
|
||||
@@ -449,7 +449,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
private void AddConPtyPane(ConPtyPaneViewModel pane)
|
||||
{
|
||||
pane.ErrorReported += OnConPtyPaneError;
|
||||
pane.CloseRequested += CloseConPtySession;
|
||||
pane.CloseRequested += OnPaneCloseRequested;
|
||||
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
||||
pane.SessionDiedAfterStartup += OnPaneSessionDied;
|
||||
pane.AbandonRunRequested += OnPaneAbandonRun;
|
||||
@@ -543,11 +543,27 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// The tile's own X: a reflex click must not silently kill a live session the way it would a
|
||||
// regular window. Confirm only when the terminal is actually running -- a dead one (StartError,
|
||||
// or HasExited i.e. the #290 exited banner) is pure cleanup and closes straight away, same as it
|
||||
// always did. Every other close path (handoff replacing a pane, submit-for-review, abandon-run)
|
||||
// still calls CloseConPtySession directly and is unaffected.
|
||||
private async void OnPaneCloseRequested(ConPtyPaneViewModel pane)
|
||||
{
|
||||
if (pane.Terminal.IsRunning)
|
||||
{
|
||||
if (ConfirmAsync is null) return;
|
||||
if (!await ConfirmAsync(Loc.T("missionControl.closeSessionConfirm"))) return;
|
||||
}
|
||||
|
||||
CloseConPtySession(pane);
|
||||
}
|
||||
|
||||
private void CloseConPtySession(ConPtyPaneViewModel pane)
|
||||
{
|
||||
if (!ConPtySessions.Contains(pane)) return;
|
||||
pane.ErrorReported -= OnConPtyPaneError;
|
||||
pane.CloseRequested -= CloseConPtySession;
|
||||
pane.CloseRequested -= OnPaneCloseRequested;
|
||||
pane.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
||||
pane.SessionDiedAfterStartup -= OnPaneSessionDied;
|
||||
pane.AbandonRunRequested -= OnPaneAbandonRun;
|
||||
@@ -601,7 +617,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
foreach (var c in ConPtySessions.ToList())
|
||||
{
|
||||
c.ErrorReported -= OnConPtyPaneError;
|
||||
c.CloseRequested -= CloseConPtySession;
|
||||
c.CloseRequested -= OnPaneCloseRequested;
|
||||
c.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
||||
c.SessionDiedAfterStartup -= OnPaneSessionDied;
|
||||
c.AbandonRunRequested -= OnPaneAbandonRun;
|
||||
|
||||
@@ -39,6 +39,11 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
|
||||
[ObservableProperty] private string _name = "";
|
||||
[ObservableProperty] private string _workingDir = "";
|
||||
// Recomputed whenever WorkingDir changes (typing or Browse) — RepoScanner.IsRepo is the same
|
||||
// check RepoImport uses, so a folder either scans in there or warns here, never neither.
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
|
||||
private string _workingDirWarning = "";
|
||||
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
||||
// A manual list holds reminders: tasks created here start out manual (TaskEntity.IsManual).
|
||||
[ObservableProperty] private bool _isManual;
|
||||
@@ -55,7 +60,33 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
// 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]
|
||||
[NotifyPropertyChangedFor(nameof(ShowTicketProject))]
|
||||
private bool _ticketsAvailable;
|
||||
|
||||
/// <summary>The dropdown needs a configured ticket system *and* a list — folders link to nothing.</summary>
|
||||
public bool ShowTicketProject => TicketsAvailable && !IsFolder;
|
||||
|
||||
// ---- Folder mode ----
|
||||
// A folder is a sidebar grouping, not a list: it carries only a name and an icon, so every
|
||||
// other field/section collapses. Reached two ways — the "create a folder instead" toggle in
|
||||
// the create flow (FolderId still null), and editing an existing folder (FolderId set).
|
||||
// There is no list <-> folder conversion: the toggle is offered on create only.
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HeaderTitle))]
|
||||
[NotifyPropertyChangedFor(nameof(DeleteLabel))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowTicketProject))]
|
||||
private bool _isFolder;
|
||||
|
||||
/// <summary>True only while creating — an existing list can't become a folder or back.</summary>
|
||||
[ObservableProperty] private bool _canToggleFolder;
|
||||
|
||||
private string? _folderId;
|
||||
|
||||
// Resolved rather than a {loc:Tr} key because the text depends on IsFolder.
|
||||
public string HeaderTitle => Loc.T(IsFolder ? "modals.listSettings.folderTitle" : "modals.listSettings.title");
|
||||
|
||||
public string DeleteLabel => Loc.T(IsFolder ? "modals.listSettings.deleteFolder" : "modals.listSettings.deleteList");
|
||||
[ObservableProperty] private TicketProjectOption? _selectedTicketProject;
|
||||
public ObservableCollection<TicketProjectOption> TicketProjects { get; } = new();
|
||||
|
||||
@@ -123,8 +154,25 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
string defaultCommitType,
|
||||
bool isManual = false,
|
||||
bool findingsTracked = false,
|
||||
string? icon = null)
|
||||
=> _ = LoadAsync(listId, name, workingDir, defaultCommitType, isManual, findingsTracked, icon);
|
||||
string? icon = null,
|
||||
bool canToggleFolder = false)
|
||||
{
|
||||
CanToggleFolder = canToggleFolder;
|
||||
_ = LoadAsync(listId, name, workingDir, defaultCommitType, isManual, findingsTracked, icon);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the modal on an existing folder: name + icon only, no list config to load and
|
||||
/// nothing to fetch from the worker, so this needs no async counterpart.
|
||||
/// </summary>
|
||||
public void BeginLoadFolder(string folderId, string name, string? icon)
|
||||
{
|
||||
_folderId = folderId;
|
||||
IsFolder = true;
|
||||
CanToggleFolder = false;
|
||||
Name = name;
|
||||
Icon = IconOptions.Contains(icon) ? icon! : DefaultIcon;
|
||||
}
|
||||
|
||||
public async Task LoadAsync(
|
||||
string listId,
|
||||
@@ -207,12 +255,29 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanSave() => !IsLoading && !_loadFailed;
|
||||
private bool CanSave() => !IsLoading && !_loadFailed && (IsFolder || string.IsNullOrEmpty(WorkingDirWarning));
|
||||
private bool CanDelete() => !IsLoading;
|
||||
|
||||
// A manual list without a WorkingDir is untouched (empty is always valid) — this only fires
|
||||
// for the "picked a folder that turns out not to be a repo" case the audit found: it would
|
||||
// otherwise only surface as a Failed task the first time the worker tries to use it.
|
||||
partial void OnWorkingDirChanged(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) { WorkingDirWarning = ""; return; }
|
||||
WorkingDirWarning = !Directory.Exists(value)
|
||||
? Loc.T("vm.listSettings.workingDirMissing")
|
||||
: RepoScanner.IsRepo(value) ? "" : Loc.T("vm.listSettings.workingDirNotRepo");
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSave))]
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (IsFolder)
|
||||
{
|
||||
await SaveFolderAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
await _worker.UpdateListAsync(new UpdateListDto(
|
||||
ListId,
|
||||
string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name,
|
||||
@@ -239,13 +304,49 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
private async Task SaveFolderAsync()
|
||||
{
|
||||
var name = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name;
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var lists = new ListRepository(ctx);
|
||||
|
||||
if (_folderId is null)
|
||||
{
|
||||
await lists.AddFolderAsync(new ListFolderEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Name = name,
|
||||
Icon = Icon,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
// The create flow pre-creates a list row for this modal to save against; a folder
|
||||
// doesn't need it. Deleted=true (it genuinely is) makes the caller do a full reload,
|
||||
// which is what picks the new folder up.
|
||||
await lists.DeleteAsync(ListId);
|
||||
Deleted = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
await lists.RenameFolderAsync(_folderId, name, Icon);
|
||||
}
|
||||
|
||||
Saved = true;
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanDelete))]
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (IsFolder)
|
||||
{
|
||||
await DeleteFolderAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
var displayName = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name;
|
||||
if (ConfirmAsync is not null)
|
||||
{
|
||||
var ok = await ConfirmAsync($"Delete list \"{displayName}\" and all its tasks? This cannot be undone.");
|
||||
var ok = await ConfirmAsync(Loc.T("vm.listSettings.confirmDeleteList", displayName));
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
@@ -262,7 +363,35 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
|| ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true))
|
||||
{
|
||||
if (ShowErrorAsync is not null)
|
||||
await ShowErrorAsync("This list has planning sessions with child tasks. Discard those first, then delete the list.");
|
||||
await ShowErrorAsync(Loc.T("vm.listSettings.deleteBlockedPlanning"));
|
||||
return;
|
||||
}
|
||||
|
||||
Deleted = true;
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
private async Task DeleteFolderAsync()
|
||||
{
|
||||
// Create mode with the folder toggle on: nothing has been written yet, so this is a cancel
|
||||
// — and the caller's rollback drops the pre-created list row.
|
||||
if (_folderId is null) { CloseAction?.Invoke(); return; }
|
||||
|
||||
var displayName = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name;
|
||||
if (ConfirmAsync is not null)
|
||||
{
|
||||
var ok = await ConfirmAsync(Loc.T("vm.listSettings.confirmDeleteFolder", displayName));
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
await new ListRepository(ctx).DeleteFolderAsync(_folderId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorReported?.Invoke(ex.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,15 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
||||
|
||||
public ObservableCollection<MergeHelperTaskRowViewModel> Tasks { get; } = new();
|
||||
|
||||
/// <summary>Model choices for the triage session. Only opus/fable are offered on purpose —
|
||||
/// triage is the dedupe/refine leverage point; cheaper models aren't worth a picker slot.</summary>
|
||||
public IReadOnlyList<string> ModelOptions { get; } = new[] { "opus", "fable" };
|
||||
|
||||
[ObservableProperty] private string _selectedModel = Data.Models.ModelRegistry.HandlerTriageAlias;
|
||||
|
||||
/// <summary>Hidden in review mode — those runs start directly at the merge phase, no triage.</summary>
|
||||
public bool ShowModelPicker => !_reviewMode;
|
||||
|
||||
[ObservableProperty] private string _scopeLabel = "";
|
||||
|
||||
public bool HasTasks => Tasks.Count > 0;
|
||||
@@ -100,6 +109,7 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
||||
{
|
||||
_reviewMode = true;
|
||||
ScopeLabel = Loc.T("modals.mergeHelper.scopeReview");
|
||||
OnPropertyChanged(nameof(ShowModelPicker));
|
||||
}
|
||||
|
||||
/// <summary>Groups a confirmed review-mode selection by owning list, preserving row order —
|
||||
|
||||
@@ -31,6 +31,10 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
[ObservableProperty] private string? _warningMessage;
|
||||
[ObservableProperty] private string? _successMessage;
|
||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private bool _hasConflict;
|
||||
|
||||
/// The merge was refused because the target working dir is dirty — offer the stash retry
|
||||
/// instead of dead-ending on the error text.
|
||||
[ObservableProperty] private bool _showStashRetry;
|
||||
[ObservableProperty] private IReadOnlyList<string> _conflictFiles = Array.Empty<string>();
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
@@ -84,22 +88,29 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
!IsBusy && !HasConflict && !string.IsNullOrWhiteSpace(SelectedBranch);
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSubmit))]
|
||||
private async Task SubmitAsync()
|
||||
private Task SubmitAsync() => RunMergeAsync(autostash: false);
|
||||
|
||||
/// Retry after "dirty_target": git stashes the target working tree's uncommitted changes,
|
||||
/// merges, and restores them afterwards.
|
||||
[RelayCommand(CanExecute = nameof(CanSubmit))]
|
||||
private Task StashAndMergeAsync() => RunMergeAsync(autostash: true);
|
||||
|
||||
private async Task RunMergeAsync(bool autostash)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(SelectedBranch)) return;
|
||||
IsBusy = true;
|
||||
ErrorMessage = null;
|
||||
WarningMessage = null;
|
||||
SuccessMessage = null;
|
||||
ShowStashRetry = false;
|
||||
// Subscribed only for the duration of the call: the worker's broadcast reaches every
|
||||
// client, and a transient VM left on that event would outlive its window.
|
||||
ProgressMessage = Loc.T("vm.merge.progressMerging");
|
||||
_worker.MergeProgressEvent += OnMergeProgress;
|
||||
_worker.OperationProgressEvent += OnOperationProgress;
|
||||
try
|
||||
{
|
||||
var result = await _worker.MergeTaskAsync(
|
||||
TaskId, SelectedBranch!, RemoveWorktree, CommitMessage);
|
||||
TaskId, SelectedBranch!, RemoveWorktree, CommitMessage, autostash);
|
||||
|
||||
switch (result.Status)
|
||||
{
|
||||
@@ -131,6 +142,12 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
// point of the gate.
|
||||
ErrorMessage = result.ErrorMessage ?? Loc.T("vm.merge.verifyFailed");
|
||||
break;
|
||||
case "dirty_target":
|
||||
// Nothing landed -- the target working dir has uncommitted changes. Offer the
|
||||
// stash retry; git restores the changes after the merge.
|
||||
ErrorMessage = result.ErrorMessage ?? Loc.T("vm.merge.dirtyTarget");
|
||||
ShowStashRetry = true;
|
||||
break;
|
||||
case "untracked_collision":
|
||||
// Nothing landed -- the merge was refused before touching the repo. Show the
|
||||
// real message (it names the colliding path and size); the locale string is
|
||||
@@ -148,34 +165,23 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
}
|
||||
finally
|
||||
{
|
||||
_worker.MergeProgressEvent -= OnMergeProgress;
|
||||
_worker.OperationProgressEvent -= OnOperationProgress;
|
||||
ProgressMessage = null;
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMergeProgress(string taskId, string phase, int elapsedSeconds)
|
||||
{
|
||||
if (taskId != TaskId) return;
|
||||
// Rebasing is handled by OnOperationProgress (needs the branch total, which this
|
||||
// elapsed-seconds-only forwarder drops) -- don't overwrite it with the generic fallback.
|
||||
if (phase == MergePhaseRebasing) return;
|
||||
ProgressMessage = phase switch
|
||||
{
|
||||
MergePhaseVerifying => Loc.T("vm.merge.progressVerifying", FormatElapsed(elapsedSeconds)),
|
||||
_ => Loc.T("vm.merge.progressMerging"),
|
||||
};
|
||||
}
|
||||
|
||||
// Separate event: MergeProgressEvent forwards elapsed-seconds-only (verifying), but the
|
||||
// rebase phase needs the branch count too, which only rides the untrimmed OperationProgress.
|
||||
private void OnOperationProgress(string opKey, string phase, int current, int total)
|
||||
{
|
||||
if (opKey != TaskId || phase != MergePhaseRebasing) return;
|
||||
ProgressMessage = total > 0
|
||||
? $"{Loc.T("ops.worker.rebasingAfterMerge")} ({current}/{total})"
|
||||
: Loc.T("ops.worker.rebasingAfterMerge");
|
||||
if (opKey != TaskId) return;
|
||||
ProgressMessage = phase switch
|
||||
{
|
||||
MergePhaseRebasing => total > 0
|
||||
? $"{Loc.T("ops.worker.rebasingAfterMerge")} ({current}/{total})"
|
||||
: Loc.T("ops.worker.rebasingAfterMerge"),
|
||||
MergePhaseVerifying => Loc.T("vm.merge.progressVerifying", FormatElapsed(current)),
|
||||
_ => Loc.T("vm.merge.progressMerging"),
|
||||
};
|
||||
}
|
||||
|
||||
/// Mirrors TaskMergeService.PhaseVerifying — a hub payload token, not a display string.
|
||||
|
||||
@@ -44,7 +44,7 @@ public sealed partial class RepoImportModalViewModel : ViewModelBase
|
||||
|
||||
public int CreateCount => Repos.Count(r => r.IsChecked && !r.AlreadyAdded);
|
||||
public bool CanCreate => CreateCount > 0;
|
||||
public string CreateButtonText => $"Create {CreateCount} list(s)";
|
||||
public string CreateButtonText => Loc.T("vm.repoImport.createButton", CreateCount);
|
||||
public bool HasFolders => _folders.Count > 0;
|
||||
|
||||
public RepoImportModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient workerClient)
|
||||
|
||||
@@ -119,8 +119,13 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
|
||||
foreach (var row in ModelPresets)
|
||||
if (row.MaxTurns is < 1 or > 200)
|
||||
return $"Max turns for {row.Model} must be between 1 and 200.";
|
||||
if (!IsValidWindowBound(PowerUseWindowStart) || !IsValidWindowBound(PowerUseWindowEnd))
|
||||
return Loc.T("settings.general.powerUseWindowInvalid");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsValidWindowBound(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) || PowerUseWindow.Parse(value) is not null;
|
||||
}
|
||||
|
||||
/// <summary>One row of the per-model preset table. Single consumer, so it lives here.</summary>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
@@ -11,6 +13,9 @@ public sealed partial class WorkerConnectionModalViewModel : ViewModelBase
|
||||
private readonly WorkerLocator _workerLocator;
|
||||
private readonly InstallerLocator _installerLocator;
|
||||
|
||||
// Test seam: real Process.Start by default, swappable to simulate a blocked/failed launch.
|
||||
internal Action<ProcessStartInfo> ProcessStarter { get; set; } = psi => Process.Start(psi);
|
||||
|
||||
public WorkerConnectionModalViewModel(WorkerLocator workerLocator, InstallerLocator installerLocator)
|
||||
{
|
||||
_workerLocator = workerLocator;
|
||||
@@ -19,15 +24,29 @@ public sealed partial class WorkerConnectionModalViewModel : ViewModelBase
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
[ObservableProperty] private string? _errorText;
|
||||
|
||||
[RelayCommand] private void Close() => CloseAction?.Invoke();
|
||||
|
||||
[RelayCommand]
|
||||
private void StartWorker()
|
||||
{
|
||||
var exe = _workerLocator.Find();
|
||||
if (exe is null) return;
|
||||
try { Process.Start(new ProcessStartInfo(exe) { UseShellExecute = true }); }
|
||||
catch { /* nothing useful to show */ }
|
||||
if (exe is null)
|
||||
{
|
||||
ErrorText = Loc.T("modals.workerConnection.workerNotFound");
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
ProcessStarter(new ProcessStartInfo(exe) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorText = Loc.T("modals.workerConnection.startFailed", ex.Message);
|
||||
return;
|
||||
}
|
||||
ErrorText = null;
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
@@ -35,18 +54,26 @@ public sealed partial class WorkerConnectionModalViewModel : ViewModelBase
|
||||
private void RerunInstaller()
|
||||
{
|
||||
var path = _installerLocator.Find();
|
||||
if (path is null) return;
|
||||
if (path is null)
|
||||
{
|
||||
ErrorText = Loc.T("modals.workerConnection.installerNotFound");
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
// See IslandsShellViewModel.UpdateNow: an inherited CWD inside the install dir
|
||||
// makes the installer block its own app\ rename.
|
||||
Process.Start(new ProcessStartInfo(path)
|
||||
ProcessStarter(new ProcessStartInfo(path)
|
||||
{
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = Path.GetTempPath(),
|
||||
});
|
||||
Environment.Exit(0);
|
||||
}
|
||||
catch { /* nothing useful to show */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorText = Loc.T("modals.workerConnection.startFailed", ex.Message);
|
||||
return;
|
||||
}
|
||||
Environment.Exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,7 +415,8 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||
private bool CanMergeAll() => !BatchMergeStatus.IsRunning && SelectedCount > 0 && !string.IsNullOrWhiteSpace(SelectedTarget);
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanMergeAll))]
|
||||
private Task MergeAll() => MergeSelectedAsync(_worker.MergeTaskAsync);
|
||||
private Task MergeAll() => MergeSelectedAsync(
|
||||
(taskId, target, removeWorktree, message) => _worker.MergeTaskAsync(taskId, target, removeWorktree, message));
|
||||
|
||||
[RelayCommand]
|
||||
private void ResolveConflict(WorktreeOverviewRowViewModel? row)
|
||||
|
||||
@@ -43,7 +43,13 @@ public partial class InteractiveTerminalView : UserControl
|
||||
if (DataContext is not InteractiveTerminalViewModel vm) return;
|
||||
|
||||
e.Handled = true;
|
||||
_ = vm.PasteAsync();
|
||||
_ = PasteAndReportAsync(vm);
|
||||
}
|
||||
|
||||
private static async System.Threading.Tasks.Task PasteAndReportAsync(InteractiveTerminalViewModel vm)
|
||||
{
|
||||
if (!await vm.PasteAsync())
|
||||
vm.ReportSendFailed();
|
||||
}
|
||||
|
||||
private static bool IsFilesDrop(DragEventArgs e) => e.DataTransfer?.Contains(DataFormat.File) == true;
|
||||
@@ -62,7 +68,13 @@ public partial class InteractiveTerminalView : UserControl
|
||||
e.Handled = true;
|
||||
|
||||
var text = FormatDroppedPaths(e.DataTransfer!.TryGetFiles()?.Select(i => i.TryGetLocalPath()));
|
||||
if (text.Length > 0) _ = vm.SendTextAsync(text);
|
||||
if (text.Length > 0) _ = SendTextAndReportAsync(vm, text);
|
||||
}
|
||||
|
||||
private static async System.Threading.Tasks.Task SendTextAndReportAsync(InteractiveTerminalViewModel vm, string text)
|
||||
{
|
||||
if (!await vm.SendTextAsync(text))
|
||||
vm.ReportSendFailed();
|
||||
}
|
||||
|
||||
/// <summary>Joins dropped file paths into one insertable chunk: quoted when they contain a
|
||||
|
||||
@@ -88,8 +88,8 @@
|
||||
ToolTip.Tip="{loc:Tr details.toggleEditPreviewTip}"
|
||||
Command="{Binding ToggleEditDescriptionCommand}">
|
||||
<Panel>
|
||||
<TextBlock Text="Preview" IsVisible="{Binding IsEditingDescription}"/>
|
||||
<TextBlock Text="Edit" IsVisible="{Binding !IsEditingDescription}"/>
|
||||
<TextBlock Text="{loc:Tr details.stepsCard.preview}" IsVisible="{Binding IsEditingDescription}"/>
|
||||
<TextBlock Text="{loc:Tr details.stepsCard.edit}" IsVisible="{Binding !IsEditingDescription}"/>
|
||||
</Panel>
|
||||
</Button>
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
<!-- Steps -->
|
||||
<StackPanel IsVisible="{Binding IsStepsSection}" Spacing="6">
|
||||
<TextBox Text="{Binding NewSubtaskTitle, Mode=TwoWay}"
|
||||
PlaceholderText="Add step…"
|
||||
PlaceholderText="{loc:Tr details.stepsCard.addStepPlaceholder}"
|
||||
Padding="8"
|
||||
Background="{DynamicResource Surface3Brush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
|
||||
@@ -41,6 +41,14 @@
|
||||
</Viewbox>
|
||||
<TextBlock Classes="meta" Text="{Binding Task.Branch}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Dependency-wait line: this task is Queued but blocked on an unmet predecessor (a
|
||||
task_dependencies edge, or the planning chain's BlockedByTaskId). -->
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,4,0,0"
|
||||
IsVisible="{Binding Task.IsWaiting, FallbackValue=False}">
|
||||
<TextBlock Classes="meta" Text="{loc:Tr details.waitingOnPrefix}"/>
|
||||
<TextBlock Classes="meta" Text="{Binding Task.DependencyWaitLabel}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Column 1: trash button (not running) -->
|
||||
|
||||
@@ -172,12 +172,12 @@
|
||||
TextWrapping="Wrap" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="btn accent" Content="Continue"
|
||||
<Button Classes="btn accent" Content="{loc:Tr details.workConsole.continue}"
|
||||
Command="{Binding ContinueCommand}"
|
||||
IsVisible="{Binding ShowContinue}"
|
||||
ToolTip.Tip="{loc:Tr session.continueNoSessionTip}"
|
||||
ToolTip.ShowOnDisabled="True" />
|
||||
<Button Classes="btn" Content="Reset & Retry"
|
||||
<Button Classes="btn" Content="{loc:Tr details.workConsole.resetAndRetry}"
|
||||
Command="{Binding ResetAndRetryCommand}"
|
||||
IsVisible="{Binding ShowResetAndRetry}"
|
||||
ToolTip.Tip="{loc:Tr session.resetAndRetryInteractiveTip}"
|
||||
@@ -194,7 +194,7 @@
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Classes="tab-btn"
|
||||
Classes.active="{Binding IsOutputTab}"
|
||||
Content="Output"
|
||||
Content="{loc:Tr details.workConsole.tabOutput}"
|
||||
Command="{Binding SelectTabCommand}"
|
||||
CommandParameter="output" />
|
||||
<Button Classes="tab-btn"
|
||||
@@ -202,7 +202,7 @@
|
||||
Command="{Binding SelectTabCommand}"
|
||||
CommandParameter="git">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="Git" VerticalAlignment="Center" />
|
||||
<TextBlock Text="{loc:Tr details.workConsole.tabGit}" VerticalAlignment="Center" />
|
||||
<!-- Review-pending dot: where to act when a task awaits review -->
|
||||
<Ellipse Width="6" Height="6" VerticalAlignment="Center"
|
||||
Fill="{DynamicResource AccentBrush}"
|
||||
@@ -211,7 +211,7 @@
|
||||
</Button>
|
||||
<Button Classes="tab-btn"
|
||||
Classes.active="{Binding IsSessionTab}"
|
||||
Content="Session"
|
||||
Content="{loc:Tr details.workConsole.tabSession}"
|
||||
IsVisible="{Binding HasChildOutcomes}"
|
||||
Command="{Binding SelectTabCommand}"
|
||||
CommandParameter="session" />
|
||||
@@ -233,7 +233,7 @@
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="1" CornerRadius="8">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="section-label" Text="OUTCOME" />
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr details.workConsole.outcomeLabel}" />
|
||||
<ScrollViewer MaxHeight="160" VerticalScrollBarVisibility="Auto">
|
||||
<SelectableTextBlock Text="{Binding SessionOutcome}"
|
||||
TextWrapping="Wrap"
|
||||
@@ -309,9 +309,9 @@
|
||||
<!-- Merge controls — shown whenever there's a worktree / unit to merge.
|
||||
Header reads REVIEW while a decision is pending, otherwise MERGE. -->
|
||||
<StackPanel Spacing="14" IsVisible="{Binding Merge.ShowMergeSection}">
|
||||
<TextBlock Classes="section-label" Text="REVIEW"
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr details.workConsole.reviewLabel}"
|
||||
IsVisible="{Binding IsWaitingForReview}" />
|
||||
<TextBlock Classes="section-label" Text="MERGE"
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr details.workConsole.mergeLabel}"
|
||||
IsVisible="{Binding !IsWaitingForReview}" />
|
||||
|
||||
<!-- Change summary (review only) -->
|
||||
@@ -323,7 +323,7 @@
|
||||
|
||||
<!-- Target branch + pre-flight mergeability -->
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="Target branch" />
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr details.workConsole.targetBranch}" />
|
||||
<ComboBox ItemsSource="{Binding Merge.MergeTargetBranches}"
|
||||
SelectedItem="{Binding Merge.SelectedMergeTarget, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch" />
|
||||
@@ -344,7 +344,7 @@
|
||||
|
||||
<!-- Inspect: diff / worktree / combined diff -->
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8"
|
||||
<Button Classes="btn" Content="{loc:Tr details.workConsole.openDiff}" Margin="0,0,8,8"
|
||||
ToolTip.Tip="{loc:Tr agent.openDiffTip}"
|
||||
ToolTip.ShowOnDisabled="True"
|
||||
Command="{Binding Merge.OpenDiffCommand}" />
|
||||
@@ -353,11 +353,11 @@
|
||||
ToolTip.ShowOnDisabled="True"
|
||||
Command="{Binding Merge.OpenWorktreeCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||
<TextBlock Text="Worktree" />
|
||||
<TextBlock Text="{loc:Tr details.workConsole.worktree}" />
|
||||
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Classes="btn" Content="Review Combined Diff" Margin="0,0,8,8"
|
||||
<Button Classes="btn" Content="{loc:Tr details.workConsole.reviewCombinedDiff}" Margin="0,0,8,8"
|
||||
ToolTip.Tip="{loc:Tr agent.reviewCombinedDiffTip}"
|
||||
ToolTip.ShowOnDisabled="True"
|
||||
Command="{Binding Merge.ReviewCombinedDiffCommand}" />
|
||||
@@ -377,7 +377,7 @@
|
||||
Foreground="{DynamicResource AmberBrush}" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="meta" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AmberBrush}"
|
||||
Text="Open the diff to enable merge" />
|
||||
Text="{loc:Tr details.workConsole.openDiffHint}" />
|
||||
</StackPanel>
|
||||
<TextBlock Classes="meta" Margin="17,0,0,0"
|
||||
Text="{loc:Tr session.approveMergesUnitHint}" />
|
||||
@@ -396,23 +396,23 @@
|
||||
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}" Margin="0,0,8,8">
|
||||
<Button Classes="btn accent" Content="Approve & Merge"
|
||||
<Button Classes="btn accent" Content="{loc:Tr details.workConsole.approveMerge}"
|
||||
Command="{Binding ApproveReviewCommand}" />
|
||||
<controls:OperationIndicator Status="{Binding ApproveOp}" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}" Margin="0,0,8,8">
|
||||
<Button Classes="btn" Content="Park"
|
||||
ToolTip.Tip="Set aside — back to Idle, keeps the worktree"
|
||||
<Button Classes="btn" Content="{loc:Tr details.workConsole.park}"
|
||||
ToolTip.Tip="{loc:Tr details.workConsole.parkTip}"
|
||||
Command="{Binding ParkReviewCommand}" />
|
||||
<controls:OperationIndicator Status="{Binding ParkOp}" />
|
||||
</StackPanel>
|
||||
<Button Classes="btn" Content="Cancel" Margin="0,0,8,8"
|
||||
<Button Classes="btn" Content="{loc:Tr details.workConsole.cancel}" Margin="0,0,8,8"
|
||||
ToolTip.Tip="{loc:Tr session.mergeDrainHint}"
|
||||
ToolTip.ShowOnDisabled="True"
|
||||
Command="{Binding CancelReviewCommand}" />
|
||||
</WrapPanel>
|
||||
|
||||
<Button Classes="prompt-action" Content="Reset (discard branch)…"
|
||||
<Button Classes="prompt-action" Content="{loc:Tr details.workConsole.resetDiscardBranch}"
|
||||
ToolTip.Tip="{loc:Tr session.reviewResetTip}"
|
||||
Command="{Binding ResetReviewCommand}" />
|
||||
</StackPanel>
|
||||
@@ -434,9 +434,9 @@
|
||||
<Border Height="1" Background="{DynamicResource LineBrush}" />
|
||||
<TextBlock Classes="meta" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
Text="Worked on this by hand? Submit the worktree for review to merge it." />
|
||||
Text="{loc:Tr details.workConsole.submitForReviewHint}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}">
|
||||
<Button Classes="btn accent" Content="Submit for review" HorizontalAlignment="Left"
|
||||
<Button Classes="btn accent" Content="{loc:Tr details.workConsole.submitForReview}" HorizontalAlignment="Left"
|
||||
Command="{Binding SubmitForReviewCommand}" />
|
||||
<controls:OperationIndicator Status="{Binding SubmitOp}" />
|
||||
</StackPanel>
|
||||
@@ -466,7 +466,7 @@
|
||||
|
||||
<!-- Child outcomes -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding HasChildOutcomes}">
|
||||
<TextBlock Classes="section-label" Text="OUTCOMES" />
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr details.workConsole.outcomesLabel}" />
|
||||
<ItemsControl ItemsSource="{Binding ChildOutcomes}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ChildOutcomeRowViewModel">
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<PathIcon Data="{StaticResource Icon.Warning}"
|
||||
Foreground="{DynamicResource StatusReviewBrush}"
|
||||
Width="14" Height="14" VerticalAlignment="Center"/>
|
||||
<TextBlock Classes="section-label" Text="ROADBLOCK"
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr details.roadblockLabel}"
|
||||
Foreground="{DynamicResource StatusReviewBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
@@ -5,6 +5,152 @@
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
x:Class="ClaudeDo.Ui.Views.Islands.ListsIslandView"
|
||||
x:DataType="vm:ListsIslandViewModel">
|
||||
<!-- Implicit, type-matched templates: the sidebar's top level is one mixed ItemsControl over
|
||||
TopLevel (folders and folder-less lists interleaved), and each folder's children resolve the
|
||||
list template the same way. -->
|
||||
<UserControl.DataTemplates>
|
||||
<!-- One user-list row. Shared by the top level and by each folder's children, so the
|
||||
row (context menu, drag handlers, drop indicators) exists once. -->
|
||||
<DataTemplate DataType="vm:ListNavItemViewModel">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto">
|
||||
|
||||
<!-- Above-row drop indicator -->
|
||||
<Border Grid.Row="0" Height="2" VerticalAlignment="Center" Margin="4,0"
|
||||
Background="{DynamicResource MossBrush}" CornerRadius="1"
|
||||
IsVisible="{Binding DropHintAbove}"/>
|
||||
|
||||
<Border Grid.Row="1" Classes="list-item" Classes.active="{Binding IsActive}"
|
||||
Classes.drop-target="{Binding IsTaskDropTarget}"
|
||||
Tapped="OnItemTapped"
|
||||
DragDrop.AllowDrop="True"
|
||||
DragDrop.DragOver="OnListDragOver"
|
||||
DragDrop.Drop="OnListDrop">
|
||||
<Border.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="{loc:Tr lists.contextWorktrees}"
|
||||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenWorktreesOverviewCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<Separator IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<MenuItem Header="{loc:Tr lists.contextOpenExplorer}"
|
||||
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInExplorerCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<MenuItem Header="{loc:Tr lists.contextOpenFindings}"
|
||||
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenFindingsCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<MenuItem Header="{loc:Tr lists.contextOpenTerminal}"
|
||||
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInTerminalCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<MenuItem Header="{loc:Tr lists.contextImportTickets}"
|
||||
IsVisible="{Binding HasTicketProject}"
|
||||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).ImportTicketsCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="{loc:Tr lists.contextRemoveFromFolder}"
|
||||
IsVisible="{Binding FolderId, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).RemoveFromFolderCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<MenuItem Header="{loc:Tr lists.contextSettings}"
|
||||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</ContextMenu>
|
||||
</Border.ContextMenu>
|
||||
<Grid ColumnDefinitions="20,*,Auto">
|
||||
<!-- Left accent bar for active state -->
|
||||
<Border Grid.Column="0" Grid.ColumnSpan="3"
|
||||
Background="Transparent"
|
||||
CornerRadius="8" IsHitTestVisible="False"
|
||||
IsVisible="{Binding IsActive}">
|
||||
<Border Width="2" Height="16"
|
||||
Background="{DynamicResource AccentBrush}"
|
||||
CornerRadius="1"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||
Margin="-8,0,0,0"/>
|
||||
</Border>
|
||||
<!-- 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}"
|
||||
VerticalAlignment="Center" Margin="8,0"
|
||||
Foreground="{DynamicResource TextDimBrush}" FontSize="{StaticResource FontSizeBody}"/>
|
||||
<!-- Count -->
|
||||
<TextBlock Grid.Column="2" Classes="list-count"
|
||||
Text="{Binding Count}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Below-row drop indicator (last item only) -->
|
||||
<Border Grid.Row="2" Height="2" VerticalAlignment="Center" Margin="4,0"
|
||||
Background="{DynamicResource MossBrush}" CornerRadius="1"
|
||||
IsVisible="{Binding DropHintBelow}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- One folder: header (never selectable — tapping folds it) plus its child lists. The two
|
||||
drop indicators sit around the whole block, so "below this folder" doesn't draw a line
|
||||
between the header and its children. -->
|
||||
<DataTemplate DataType="vm:ListFolderNavItemViewModel">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto">
|
||||
<Border Grid.Row="0" Height="2" VerticalAlignment="Center" Margin="4,0"
|
||||
Background="{DynamicResource MossBrush}" CornerRadius="1"
|
||||
IsVisible="{Binding DropHintAbove}"/>
|
||||
|
||||
<StackPanel Grid.Row="1">
|
||||
<Border Classes="list-item"
|
||||
Classes.drop-target="{Binding IsDropTarget}"
|
||||
Tapped="OnFolderTapped"
|
||||
DragDrop.AllowDrop="True"
|
||||
DragDrop.DragOver="OnFolderDragOver"
|
||||
DragDrop.Drop="OnFolderDrop">
|
||||
<Border.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="{loc:Tr lists.contextFolderSettings}"
|
||||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenFolderSettingsCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</ContextMenu>
|
||||
</Border.ContextMenu>
|
||||
<Grid ColumnDefinitions="12,20,*,Auto">
|
||||
<PathIcon Grid.Column="0" Classes="list-icon"
|
||||
Width="9" Height="9"
|
||||
Data="{StaticResource Icon.ChevronRight}"
|
||||
IsVisible="{Binding IsCollapsed}"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center"/>
|
||||
<PathIcon Grid.Column="0" Classes="list-icon"
|
||||
Width="9" Height="9"
|
||||
Data="{StaticResource Icon.ChevronDown}"
|
||||
IsVisible="{Binding !IsCollapsed}"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center"/>
|
||||
<PathIcon Grid.Column="1" Classes="list-icon"
|
||||
Width="14" Height="14"
|
||||
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="2" Classes="list-label"
|
||||
Text="{Binding Name}"
|
||||
VerticalAlignment="Center" Margin="8,0"
|
||||
Foreground="{DynamicResource TextDimBrush}" FontSize="{StaticResource FontSizeBody}"/>
|
||||
<TextBlock Grid.Column="3" Classes="list-count"
|
||||
Text="{Binding Count}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Lists}"
|
||||
IsVisible="{Binding !IsCollapsed}"
|
||||
Margin="14,0,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<Border Grid.Row="2" Height="2" VerticalAlignment="Center" Margin="4,0"
|
||||
Background="{DynamicResource MossBrush}" CornerRadius="1"
|
||||
IsVisible="{Binding DropHintBelow}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</UserControl.DataTemplates>
|
||||
|
||||
<DockPanel LastChildFill="True">
|
||||
|
||||
<!-- ── Header ── -->
|
||||
@@ -133,86 +279,9 @@
|
||||
<PathIcon Data="{StaticResource Icon.Folder}" Width="14" Height="14"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
<ItemsControl ItemsSource="{Binding UserLists}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:ListNavItemViewModel">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto">
|
||||
|
||||
<!-- Above-row drop indicator -->
|
||||
<Border Grid.Row="0" Height="2" VerticalAlignment="Center" Margin="4,0"
|
||||
Background="{DynamicResource MossBrush}" CornerRadius="1"
|
||||
IsVisible="{Binding DropHintAbove}"/>
|
||||
|
||||
<Border Grid.Row="1" Classes="list-item" Classes.active="{Binding IsActive}"
|
||||
Classes.drop-target="{Binding IsTaskDropTarget}"
|
||||
Tapped="OnItemTapped"
|
||||
DragDrop.AllowDrop="True"
|
||||
DragDrop.DragOver="OnListDragOver"
|
||||
DragDrop.Drop="OnListDrop">
|
||||
<Border.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="{loc:Tr lists.contextWorktrees}"
|
||||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenWorktreesOverviewCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<Separator IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<MenuItem Header="{loc:Tr lists.contextOpenExplorer}"
|
||||
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInExplorerCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<MenuItem Header="{loc:Tr lists.contextOpenFindings}"
|
||||
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenFindingsCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<MenuItem Header="{loc:Tr lists.contextOpenTerminal}"
|
||||
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInTerminalCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<MenuItem Header="{loc:Tr lists.contextImportTickets}"
|
||||
IsVisible="{Binding HasTicketProject}"
|
||||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).ImportTicketsCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="{loc:Tr lists.contextSettings}"
|
||||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</ContextMenu>
|
||||
</Border.ContextMenu>
|
||||
<Grid ColumnDefinitions="20,*,Auto">
|
||||
<!-- Left accent bar for active state -->
|
||||
<Border Grid.Column="0" Grid.ColumnSpan="3"
|
||||
Background="Transparent"
|
||||
CornerRadius="8" IsHitTestVisible="False"
|
||||
IsVisible="{Binding IsActive}">
|
||||
<Border Width="2" Height="16"
|
||||
Background="{DynamicResource AccentBrush}"
|
||||
CornerRadius="1"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||
Margin="-8,0,0,0"/>
|
||||
</Border>
|
||||
<!-- 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}"
|
||||
VerticalAlignment="Center" Margin="8,0"
|
||||
Foreground="{DynamicResource TextDimBrush}" FontSize="{StaticResource FontSizeBody}"/>
|
||||
<!-- Count -->
|
||||
<TextBlock Grid.Column="2" Classes="list-count"
|
||||
Text="{Binding Count}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Below-row drop indicator (last item only) -->
|
||||
<Border Grid.Row="2" Height="2" VerticalAlignment="Center" Margin="4,0"
|
||||
Background="{DynamicResource MossBrush}" CornerRadius="1"
|
||||
IsVisible="{Binding DropHintBelow}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<!-- Folders and folder-less lists in one sequence, so either can be dragged above the
|
||||
other; each item picks its template from UserControl.DataTemplates by type. -->
|
||||
<ItemsControl ItemsSource="{Binding TopLevel}"/>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
@@ -17,11 +17,28 @@ public partial class ListsIslandView : UserControl
|
||||
{
|
||||
private static readonly DataFormat<string> ListRowFormat =
|
||||
DataFormat.CreateStringApplicationFormat("claudedo-list-row");
|
||||
private static readonly DataFormat<string> FolderRowFormat =
|
||||
DataFormat.CreateStringApplicationFormat("claudedo-folder-row");
|
||||
|
||||
// A folder drag only starts once the pointer has moved: DoDragDropAsync captures the pointer,
|
||||
// and starting it on press would swallow the Tapped that folds the folder. It only takes the
|
||||
// *pressed* args, so the press is kept around until the threshold is crossed.
|
||||
private ListFolderNavItemViewModel? _pendingFolderDrag;
|
||||
private PointerPressedEventArgs? _pendingFolderDragPress;
|
||||
private Point _folderDragOrigin;
|
||||
|
||||
private void CancelPendingFolderDrag()
|
||||
{
|
||||
_pendingFolderDrag = null;
|
||||
_pendingFolderDragPress = null;
|
||||
}
|
||||
|
||||
public ListsIslandView()
|
||||
{
|
||||
InitializeComponent();
|
||||
AddHandler(PointerPressedEvent, OnTunnelPointerPressed, RoutingStrategies.Tunnel);
|
||||
AddHandler(PointerMovedEvent, OnTunnelPointerMoved, RoutingStrategies.Tunnel);
|
||||
AddHandler(PointerReleasedEvent, (_, _) => CancelPendingFolderDrag(), RoutingStrategies.Tunnel);
|
||||
DataContextChanged += (_, _) =>
|
||||
{
|
||||
if (DataContext is ListsIslandViewModel vm)
|
||||
@@ -42,9 +59,19 @@ public partial class ListsIslandView : UserControl
|
||||
if (e.Source is not Visual src) return;
|
||||
|
||||
var border = FindListItemBorder(src);
|
||||
if (border?.DataContext is not ListNavItemViewModel row || row.Kind != ListKind.User) return;
|
||||
if (border is null) return;
|
||||
if (!e.GetCurrentPoint(border).Properties.IsLeftButtonPressed) return;
|
||||
|
||||
if (border.DataContext is ListFolderNavItemViewModel folder)
|
||||
{
|
||||
_pendingFolderDrag = folder;
|
||||
_pendingFolderDragPress = e;
|
||||
_folderDragOrigin = e.GetPosition(this);
|
||||
return;
|
||||
}
|
||||
|
||||
if (border.DataContext is not ListNavItemViewModel row || row.Kind != ListKind.User) return;
|
||||
|
||||
// Double-click opens the list's settings instead of starting a drag. Handled here
|
||||
// because DoDragDropAsync captures the pointer and would swallow a DoubleTapped event.
|
||||
if (e.ClickCount == 2)
|
||||
@@ -69,50 +96,149 @@ public partial class ListsIslandView : UserControl
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnTunnelPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (_pendingFolderDrag is not { } folder || _pendingFolderDragPress is not { } press) return;
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { CancelPendingFolderDrag(); return; }
|
||||
|
||||
var delta = e.GetPosition(this) - _folderDragOrigin;
|
||||
if (Math.Abs(delta.X) < 4 && Math.Abs(delta.Y) < 4) return;
|
||||
|
||||
CancelPendingFolderDrag();
|
||||
if (DataContext is not ListsIslandViewModel vm) return;
|
||||
|
||||
var data = new DataTransfer();
|
||||
data.Add(DataTransferItem.Create(FolderRowFormat, folder.Id));
|
||||
try
|
||||
{
|
||||
await DragDrop.DoDragDropAsync(press, data, DragDropEffects.Move);
|
||||
}
|
||||
finally
|
||||
{
|
||||
vm.ClearDropHints();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnFolderTapped(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is Border { DataContext: ListFolderNavItemViewModel folder }
|
||||
&& DataContext is ListsIslandViewModel vm)
|
||||
vm.ToggleFolderCommand.Execute(folder);
|
||||
}
|
||||
|
||||
/// <summary>The dragged row or folder, resolved from whichever of the two formats is present.</summary>
|
||||
private static object? DragSource(ListsIslandViewModel vm, DragEventArgs e)
|
||||
{
|
||||
if (e.DataTransfer is not { } data) return null;
|
||||
if (data.Contains(ListRowFormat))
|
||||
return data.TryGetValue(ListRowFormat) is { } listId
|
||||
? vm.UserLists.FirstOrDefault(r => r.Id == listId) : null;
|
||||
if (data.Contains(FolderRowFormat))
|
||||
return data.TryGetValue(FolderRowFormat) is { } folderId
|
||||
? vm.Folders.FirstOrDefault(f => f.Id == folderId) : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
private enum FolderDrop { None, Into, Beside }
|
||||
|
||||
/// <summary>
|
||||
/// What a drop on a folder header means. For a list the middle half moves it *into* the folder
|
||||
/// (the only route into a collapsed or empty one) while the outer quarters insert next to the
|
||||
/// folder at the top level; a folder source has no "into" — folders never nest — so it splits
|
||||
/// in half.
|
||||
/// </summary>
|
||||
private static (FolderDrop Kind, bool PlaceBelow) ClassifyFolderDrop(
|
||||
object source, ListFolderNavItemViewModel folder, double y, double height)
|
||||
{
|
||||
if (source is ListFolderNavItemViewModel dragged)
|
||||
return ReferenceEquals(dragged, folder)
|
||||
? (FolderDrop.None, false)
|
||||
: (FolderDrop.Beside, y > height / 2);
|
||||
|
||||
if (source is not ListNavItemViewModel row) return (FolderDrop.None, false);
|
||||
if (y < height * 0.25) return (FolderDrop.Beside, false);
|
||||
if (y > height * 0.75) return (FolderDrop.Beside, true);
|
||||
return row.FolderId == folder.Id ? (FolderDrop.None, false) : (FolderDrop.Into, false);
|
||||
}
|
||||
|
||||
private void OnFolderDragOver(object? sender, DragEventArgs e)
|
||||
{
|
||||
if (DataContext is not ListsIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; }
|
||||
if (sender is not Border b || b.DataContext is not ListFolderNavItemViewModel folder)
|
||||
{
|
||||
Reject(vm, e);
|
||||
return;
|
||||
}
|
||||
|
||||
var source = DragSource(vm, e);
|
||||
if (source is null) { Reject(vm, e); return; }
|
||||
|
||||
var (kind, placeBelow) = ClassifyFolderDrop(source, folder, e.GetPosition(b).Y, b.Bounds.Height);
|
||||
switch (kind)
|
||||
{
|
||||
case FolderDrop.Into:
|
||||
vm.SetFolderDropTarget(folder);
|
||||
e.DragEffects = DragDropEffects.Move;
|
||||
break;
|
||||
case FolderDrop.Beside:
|
||||
vm.SetDropHint(folder, placeBelow);
|
||||
e.DragEffects = DragDropEffects.Move;
|
||||
break;
|
||||
default:
|
||||
Reject(vm, e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnFolderDrop(object? sender, DragEventArgs e)
|
||||
{
|
||||
if (DataContext is not ListsIslandViewModel vm) return;
|
||||
try
|
||||
{
|
||||
if (sender is not Border b || b.DataContext is not ListFolderNavItemViewModel folder) return;
|
||||
var source = DragSource(vm, e);
|
||||
if (source is null) return;
|
||||
|
||||
var (kind, placeBelow) = ClassifyFolderDrop(source, folder, e.GetPosition(b).Y, b.Bounds.Height);
|
||||
vm.ClearDropHints();
|
||||
if (kind == FolderDrop.Into && source is ListNavItemViewModel row)
|
||||
await vm.MoveToFolderAsync(row, folder);
|
||||
else if (kind == FolderDrop.Beside)
|
||||
await vm.DropAsync(source, folder, placeBelow);
|
||||
}
|
||||
catch
|
||||
{
|
||||
vm.ClearDropHints();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnListDragOver(object? sender, DragEventArgs e)
|
||||
{
|
||||
if (DataContext is not ListsIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; }
|
||||
if (!e.DataTransfer?.Contains(ListRowFormat) ?? true)
|
||||
{
|
||||
e.DragEffects = DragDropEffects.None;
|
||||
vm.ClearDropHints();
|
||||
return;
|
||||
}
|
||||
if (sender is not Border b || b.DataContext is not ListNavItemViewModel target || target.Kind != ListKind.User)
|
||||
{
|
||||
e.DragEffects = DragDropEffects.None;
|
||||
vm.ClearDropHints();
|
||||
Reject(vm, e);
|
||||
return;
|
||||
}
|
||||
|
||||
var sourceId = e.DataTransfer?.TryGetValue(ListRowFormat);
|
||||
if (string.IsNullOrEmpty(sourceId) || sourceId == target.Id)
|
||||
{
|
||||
e.DragEffects = DragDropEffects.None;
|
||||
vm.ClearDropHints();
|
||||
return;
|
||||
}
|
||||
|
||||
var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2;
|
||||
var source = DragSource(vm, e);
|
||||
if (source is null || ReferenceEquals(source, target)) { Reject(vm, e); return; }
|
||||
// A folder can only be dropped at the top level, so a row inside a folder is no target.
|
||||
if (source is ListFolderNavItemViewModel && target.FolderId is not null) { Reject(vm, e); return; }
|
||||
|
||||
// Canonicalize: "drop below X" == "drop above X+1". Only the last row shows a below-line.
|
||||
ListNavItemViewModel hintRow = target;
|
||||
bool hintBelow = false;
|
||||
if (placeBelow)
|
||||
object hintTarget = target;
|
||||
var hintBelow = false;
|
||||
if (e.GetPosition(b).Y > b.Bounds.Height / 2)
|
||||
{
|
||||
var next = FindNextUserList(vm, target);
|
||||
if (next is not null) { hintRow = next; hintBelow = false; }
|
||||
else { hintRow = target; hintBelow = true; }
|
||||
if (FindNextInContainer(vm, target) is { } next) hintTarget = next;
|
||||
else hintBelow = true;
|
||||
}
|
||||
|
||||
if (hintRow.Id == sourceId)
|
||||
{
|
||||
e.DragEffects = DragDropEffects.None;
|
||||
vm.ClearDropHints();
|
||||
return;
|
||||
}
|
||||
if (ReferenceEquals(hintTarget, source)) { Reject(vm, e); return; }
|
||||
|
||||
vm.SetDropHint(hintRow, hintBelow);
|
||||
vm.SetDropHint(hintTarget, hintBelow);
|
||||
e.DragEffects = DragDropEffects.Move;
|
||||
}
|
||||
|
||||
@@ -123,15 +249,13 @@ public partial class ListsIslandView : UserControl
|
||||
{
|
||||
if (sender is not Border b || b.DataContext is not ListNavItemViewModel target || target.Kind != ListKind.User) return;
|
||||
|
||||
var sourceId = e.DataTransfer?.TryGetValue(ListRowFormat);
|
||||
if (string.IsNullOrEmpty(sourceId) || sourceId == target.Id) return;
|
||||
|
||||
var source = vm.UserLists.FirstOrDefault(r => r.Id == sourceId);
|
||||
if (source is null) return;
|
||||
var source = DragSource(vm, e);
|
||||
if (source is null || ReferenceEquals(source, target)) return;
|
||||
if (source is ListFolderNavItemViewModel && target.FolderId is not null) return;
|
||||
|
||||
var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2;
|
||||
vm.ClearDropHints();
|
||||
await vm.ReorderAsync(source, target, placeBelow);
|
||||
await vm.DropAsync(source, target, placeBelow);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -140,6 +264,12 @@ public partial class ListsIslandView : UserControl
|
||||
}
|
||||
}
|
||||
|
||||
private static void Reject(ListsIslandViewModel vm, DragEventArgs e)
|
||||
{
|
||||
e.DragEffects = DragDropEffects.None;
|
||||
vm.ClearDropHints();
|
||||
}
|
||||
|
||||
private static Border? FindListItemBorder(Visual? v)
|
||||
{
|
||||
while (v is not null)
|
||||
@@ -150,11 +280,15 @@ public partial class ListsIslandView : UserControl
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ListNavItemViewModel? FindNextUserList(ListsIslandViewModel vm, ListNavItemViewModel row)
|
||||
// Next item *within the same container* — the row below the last list of a folder belongs to
|
||||
// another container, so it is not a "drop above" candidate here. At the top level the next
|
||||
// item can be a folder, which is a valid hint target.
|
||||
private static object? FindNextInContainer(ListsIslandViewModel vm, ListNavItemViewModel row)
|
||||
{
|
||||
var idx = vm.UserLists.IndexOf(row);
|
||||
var container = vm.ContainerOf(row);
|
||||
var idx = container.IndexOf(row);
|
||||
if (idx < 0) return null;
|
||||
return idx + 1 < vm.UserLists.Count ? vm.UserLists[idx + 1] : null;
|
||||
return idx + 1 < container.Count ? container[idx + 1] : null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -142,7 +142,8 @@
|
||||
Classes.children="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=WaitingForChildren}"
|
||||
Classes.done="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Done}"
|
||||
Classes.error="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Failed}"
|
||||
Classes.queued="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Queued}">
|
||||
Classes.queued="{Binding IsQueued}"
|
||||
Classes.waiting="{Binding IsWaiting}">
|
||||
<TextBlock Text="{Binding StatusLabel}"/>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -66,6 +66,11 @@
|
||||
Command="{Binding RestartWorkerCommand}"/>
|
||||
<MenuItem Header="{loc:Tr shell.menu.usageMonitor}"
|
||||
Command="{Binding OpenUsageMonitorCommand}"/>
|
||||
<MenuItem Header="{loc:Tr shell.menu.updateClaudeCli}"
|
||||
Command="{Binding UpdateClaudeCliCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="{loc:Tr shell.menu.shutdown}"
|
||||
Command="{Binding ShutdownCommand}"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="{loc:Tr shell.menu.repositories}"
|
||||
FontSize="{StaticResource FontSizeMono}"
|
||||
|
||||
@@ -80,6 +80,11 @@ public partial class MainWindow : Window
|
||||
WindowState = WindowState.Normal;
|
||||
Activate();
|
||||
};
|
||||
vm.CloseWindow = () =>
|
||||
{
|
||||
_forceClose = true;
|
||||
Close();
|
||||
};
|
||||
if (vm.Tasks is not null)
|
||||
vm.Tasks.IsWindowVisible = WindowState != WindowState.Minimized;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,27 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Session-ended banner: process died without a start-failure already covering it -->
|
||||
<Border DockPanel.Dock="Top"
|
||||
IsVisible="{Binding Terminal.HasExitedAfterStartup}"
|
||||
Background="{DynamicResource ErrorTintBrush}"
|
||||
BorderBrush="{DynamicResource BloodBrush}"
|
||||
BorderThickness="0,0,0,1" Padding="12,6">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding ExitedBannerText}"
|
||||
Foreground="{DynamicResource BloodBrush}"
|
||||
TextWrapping="Wrap" VerticalAlignment="Center" />
|
||||
<Button Grid.Column="1" Classes="btn" Margin="12,0,0,0"
|
||||
Content="{loc:Tr missionControl.retry}"
|
||||
ToolTip.Tip="{loc:Tr missionControl.retryTip}"
|
||||
Command="{Binding RetryCommand}" />
|
||||
<Button Grid.Column="2" Classes="btn" Margin="12,0,0,0"
|
||||
Content="{loc:Tr missionControl.closeSession}"
|
||||
ToolTip.Tip="{loc:Tr missionControl.closeSession}"
|
||||
Command="{Binding CloseCommand}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Embedded ConPTY terminal, with a starting overlay until the session is spawned -->
|
||||
<Panel>
|
||||
<views:InteractiveTerminalView DataContext="{Binding Terminal}" />
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
<KeyBinding Gesture="Enter" Command="{Binding SaveCommand}"/>
|
||||
</Window.KeyBindings>
|
||||
|
||||
<ctl:ModalShell Title="{loc:Tr modals.listSettings.title}" CloseCommand="{Binding CancelCommand}">
|
||||
<ctl:ModalShell Title="{Binding HeaderTitle}" CloseCommand="{Binding CancelCommand}">
|
||||
<ctl:ModalShell.Footer>
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
|
||||
<Button Grid.Column="0" Content="{loc:Tr modals.listSettings.deleteList}" Classes="danger"
|
||||
<Button Grid.Column="0" Content="{Binding DeleteLabel}" Classes="danger"
|
||||
Command="{Binding DeleteCommand}" MinWidth="90"/>
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="btn" Content="{loc:Tr settings.cancel}" Command="{Binding CancelCommand}" MinWidth="90"/>
|
||||
@@ -40,8 +40,12 @@
|
||||
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}"/>
|
||||
<!-- A folder runs nothing, so it has neither agent config nor a verify command. The
|
||||
TabItems stay in place (index-driven host) and just become unreachable. -->
|
||||
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionAgent}"
|
||||
IsVisible="{Binding !IsFolder}"/>
|
||||
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionVerify}"
|
||||
IsVisible="{Binding !IsFolder}"/>
|
||||
</ListBox>
|
||||
|
||||
<Border Grid.Column="1" Background="{DynamicResource LineBrush}" Width="1" Margin="0,8"/>
|
||||
@@ -69,48 +73,61 @@
|
||||
<TabItem Header="{loc:Tr modals.listSettings.sectionGeneral}">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="12" Margin="0,8,0,0">
|
||||
<!-- Create flow only: turns this into a sidebar folder (name + icon, nothing else).
|
||||
Deliberately not a second sidebar button — the "+" and the repo-import button
|
||||
already sit there. -->
|
||||
<StackPanel Spacing="4" IsVisible="{Binding CanToggleFolder}">
|
||||
<CheckBox IsChecked="{Binding IsFolder, Mode=TwoWay}"
|
||||
Content="{loc:Tr modals.listSettings.createAsFolder}"/>
|
||||
<TextBlock Text="{loc:Tr modals.listSettings.createAsFolderHint}"
|
||||
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.name}"/>
|
||||
<TextBox Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<StackPanel Spacing="4" IsVisible="{Binding !IsFolder}">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.workingDirectory}"/>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding WorkingDir}" PlaceholderText="{loc:Tr modals.listSettings.workingDirectoryPlaceholder}" />
|
||||
<Button Classes="btn" Grid.Column="1" Content="{loc:Tr modals.listSettings.browse}" Margin="8,0,0,0" Click="BrowseClicked" />
|
||||
</Grid>
|
||||
<TextBlock Text="{Binding WorkingDirWarning}"
|
||||
IsVisible="{Binding WorkingDirWarning, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
Foreground="{DynamicResource StatusErrorBrush}" FontSize="12" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<StackPanel Spacing="4" IsVisible="{Binding !IsFolder}">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.defaultCommitType}"/>
|
||||
<ComboBox ItemsSource="{Binding CommitTypeOptions}"
|
||||
SelectedItem="{Binding DefaultCommitType, Mode=TwoWay}"
|
||||
HorizontalAlignment="Left" MinWidth="160" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<StackPanel Spacing="4" IsVisible="{Binding !IsFolder}">
|
||||
<CheckBox IsChecked="{Binding IsManual, Mode=TwoWay}"
|
||||
Content="{loc:Tr modals.listSettings.manualList}"/>
|
||||
<TextBlock Text="{loc:Tr modals.listSettings.manualListHint}"
|
||||
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<StackPanel Spacing="4" IsVisible="{Binding !IsFolder}">
|
||||
<CheckBox IsChecked="{Binding FindingsTracked, Mode=TwoWay}"
|
||||
Content="{loc:Tr modals.listSettings.findingsTracked}"/>
|
||||
<TextBlock Text="{loc:Tr modals.listSettings.findingsTrackedHint}"
|
||||
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<StackPanel Spacing="4" IsVisible="{Binding !IsFolder}">
|
||||
<CheckBox IsChecked="{Binding PowerUseAllowed, Mode=TwoWay}"
|
||||
Content="{loc:Tr modals.listSettings.powerUseAllowed}"/>
|
||||
<TextBlock Text="{loc:Tr modals.listSettings.powerUseAllowedHint}"
|
||||
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4" IsVisible="{Binding TicketsAvailable}">
|
||||
<StackPanel Spacing="4" IsVisible="{Binding ShowTicketProject}">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.ticketProject}"/>
|
||||
<ComboBox ItemsSource="{Binding TicketProjects}"
|
||||
SelectedItem="{Binding SelectedTicketProject, Mode=TwoWay}"
|
||||
|
||||
@@ -29,11 +29,18 @@
|
||||
|
||||
<!-- Body: scope + select-all/none toolbar, headers, checkbox rows -->
|
||||
<DockPanel>
|
||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,Auto,Auto" Margin="20,12,20,6">
|
||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,Auto,Auto,Auto" Margin="20,12,20,6">
|
||||
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding ScopeLabel}" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Classes="btn" Content="{loc:Tr modals.mergeHelper.selectAll}"
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6" Margin="0,0,12,0"
|
||||
IsVisible="{Binding ShowModelPicker}"
|
||||
ToolTip.Tip="{loc:Tr modals.mergeHelper.modelTip}">
|
||||
<TextBlock Classes="meta" Text="{loc:Tr modals.mergeHelper.modelLabel}" VerticalAlignment="Center"/>
|
||||
<ComboBox MinWidth="90" ItemsSource="{Binding ModelOptions}"
|
||||
SelectedItem="{Binding SelectedModel, Mode=TwoWay}"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="2" Classes="btn" Content="{loc:Tr modals.mergeHelper.selectAll}"
|
||||
Command="{Binding SelectAllCommand}" IsEnabled="{Binding HasTasks}"/>
|
||||
<Button Grid.Column="2" Classes="btn" Content="{loc:Tr modals.mergeHelper.selectNone}"
|
||||
<Button Grid.Column="3" Classes="btn" Content="{loc:Tr modals.mergeHelper.selectNone}"
|
||||
Command="{Binding SelectNoneCommand}" IsEnabled="{Binding HasTasks}" Margin="8,0,0,0"/>
|
||||
</Grid>
|
||||
|
||||
|
||||
@@ -70,6 +70,11 @@
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding ErrorMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
|
||||
<Button Classes="btn" HorizontalAlignment="Left"
|
||||
Content="{loc:Tr modals.merge.stashAndMerge}"
|
||||
Command="{Binding StashAndMergeCommand}"
|
||||
IsVisible="{Binding ShowStashRetry}" />
|
||||
|
||||
<Border Classes="danger-box"
|
||||
IsVisible="{Binding HasConflict}">
|
||||
<StackPanel Spacing="4">
|
||||
|
||||
@@ -190,7 +190,8 @@
|
||||
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
|
||||
<Grid ColumnDefinitions="90,12,*,12,110" Margin="0,4,0,0">
|
||||
<TextBlock Grid.Column="0" Classes="eyebrow" Text="{loc:Tr settings.general.model}"/>
|
||||
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr settings.general.effort}"/>
|
||||
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr settings.general.effort}"
|
||||
ToolTip.Tip="{loc:Tr settings.general.effortHint}"/>
|
||||
<TextBlock Grid.Column="4" Classes="eyebrow" Text="{loc:Tr settings.general.maxTurns}"/>
|
||||
</Grid>
|
||||
<ItemsControl ItemsSource="{Binding General.ModelPresets}">
|
||||
@@ -307,7 +308,8 @@
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="{loc:Tr settings.sidebar.categoryPrime}">
|
||||
<TabItem Header="{loc:Tr settings.sidebar.categoryPrime}"
|
||||
ToolTip.Tip="{loc:Tr settings.sidebar.categoryPrimeHint}">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="12" Margin="0,8,0,0">
|
||||
<Border IsVisible="{Binding !HasLinkedRepo}"
|
||||
|
||||
@@ -16,10 +16,14 @@
|
||||
</Window.KeyBindings>
|
||||
|
||||
<ctl:ModalShell Title="{loc:Tr modals.workerConnection.title}" CloseCommand="{Binding CloseCommand}">
|
||||
<Grid RowDefinitions="*,Auto" Margin="20,16">
|
||||
<Grid RowDefinitions="*,Auto,Auto" Margin="20,16">
|
||||
<TextBlock Grid.Row="0" Classes="meta" TextWrapping="Wrap"
|
||||
Text="{loc:Tr modals.workerConnection.body}"/>
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8"
|
||||
<TextBlock Grid.Row="1" TextWrapping="Wrap" Margin="0,8,0,0"
|
||||
Foreground="{DynamicResource StatusErrorBrush}"
|
||||
IsVisible="{Binding ErrorText, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||
Text="{Binding ErrorText}"/>
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8"
|
||||
HorizontalAlignment="Right" Margin="0,16,0,0">
|
||||
<Button Classes="btn" Content="{loc:Tr modals.workerConnection.dismiss}" Command="{Binding CloseCommand}"/>
|
||||
<Button Classes="btn" Content="{loc:Tr modals.workerConnection.rerunInstaller}" Command="{Binding RerunInstallerCommand}"/>
|
||||
|
||||
@@ -30,7 +30,7 @@ Worker/
|
||||
Findings/ — FindingsStore + FindingsStoreLocator: the per-project `.claudedo/` trap store
|
||||
Git/ — GitExcludeWriter (`.git/info/exclude`) + GitHead, shared by skill seeding and findings
|
||||
Config/ — WorkerConfig
|
||||
Hub/ — WorkerHub, HubBroadcaster
|
||||
Hub/ — WorkerHub, HubBroadcaster, ConnectedClientTracker (connected-UI-client count)
|
||||
Logging/ — LogRingBuffer (30-min window) + BroadcastLogSink (Serilog → footer + overlay)
|
||||
Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService
|
||||
Prime/ — "Prime Claude" schedules: PrimeScheduler, PrimeRunner, PrimePrompts,
|
||||
@@ -50,7 +50,7 @@ subfolder within their area; the namespace stays the area namespace.
|
||||
## Architecture
|
||||
|
||||
- **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821`
|
||||
- **TaskStateService** — the **only** component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`, and the `task_dependencies` edges. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and advances the planning chain on child terminal transitions. Every transition broadcasts via a private `NotifyAsync(taskId)` — never call `_broadcaster.TaskUpdated` directly from this class, `NotifyAsync` also drives `TicketStatusSync.SyncAsync` (see Tickets below) and a direct call skips that transition's ticket write-back. `SetDependsOnAsync` replaces the task's whole dependency **set** (empty list clears it) and rejects a self-reference, an unknown dependency id, or any link that would create a cycle — a BFS over the whole `task_dependencies` edge set from every proposed predecessor, so a multi-edge loop (A→B→C→A) is caught too, and one bad edge leaves the entire set unwritten.
|
||||
- **TaskStateService** — the **only** component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`, and the `task_dependencies` edges. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and advances the planning chain on child terminal transitions. `SubmitForChildrenAsync` (Running → WaitingForChildren) also chains and immediately queues a normal parent's own Idle/Queued children via `PlanningChainCoordinator.SetupChainAsync(enqueue: true)` — unlike a planning parent (`PlanningPhase != None`), whose children stay untouched here and wait for the user's explicit "Queue plan" (`QueuePlanAsync`); a child already `Running` makes `SetupChainAsync` throw, which is caught and logged rather than failing the parent's own transition. Every transition broadcasts via a private `NotifyAsync(taskId)` — never call `_broadcaster.TaskUpdated` directly from this class, `NotifyAsync` also drives `TicketStatusSync.SyncAsync` (see Tickets below) and a direct call skips that transition's ticket write-back. `SetDependsOnAsync` replaces the task's whole dependency **set** (empty list clears it) and rejects a self-reference, an unknown dependency id, or any link that would create a cycle — a BFS over the whole `task_dependencies` edge set from every proposed predecessor, so a multi-edge loop (A→B→C→A) is caught too, and one bad edge leaves the entire set unwritten.
|
||||
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0`, schedule, and "no `task_dependencies` edge whose predecessor isn't `done`" (doubly negated on purpose: zero edges stays eligible, all edges must be done, and an edge pointing at a vanished task keeps blocking); QueueService is a thin `BackgroundService` looping on the waker, dispatching via `TaskRunner`. Per tick it also applies the usage throttle and gate → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
|
||||
- **RunCancellationRegistry** — taskId → running-run CTS. Lets `TaskStateService.CancelAsync` kill a cancelled task's process without a DI cycle.
|
||||
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
|
||||
@@ -130,7 +130,7 @@ Full flow, invariants, and model/effort/max-turns resolution (including the low-
|
||||
- **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever app settings specify). Prompt via stdin, NDJSON from stdout. CancellationToken kills the process tree. Resolves `_cfg.ClaudeBin` via `ExecutableResolver` (`ClaudeDo.Data`, shared with `ClaudeCliPreflight` and the Installer's checks) before spawning — a `.cmd`/`.bat` shim (e.g. an npm-installed `claude`) is launched through `cmd.exe /c` since `UseShellExecute = false` can't exec a shim directly; a resolved `.exe` starts exactly as before. Throws if nothing resolves. *(Not yet on `main` — see `Environment Checks` in `ClaudeDo.Installer/CLAUDE.md`.)*
|
||||
- **ClaudeArgsBuilder** — `--model`, `--effort`, `--max-turns`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
|
||||
- **StreamAnalyzer** — parses NDJSON; extracts session_id, token counts, turn counts, result text, structured output. Replaced MessageParser.
|
||||
- **WorktreeManager** — worktrees on `claudedo/{taskId[:8]}` branches; commits with semantic messages, updates DB with head commit + diff stats
|
||||
- **WorktreeManager** — worktrees on `claudedo/{id}` branches (full task GUID, dashes stripped — a prefix isn't enough to rule out collisions); commits with semantic messages, updates DB with head commit + diff stats
|
||||
- **CommitMessageBuilder** — `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`; `BuildMerge` is the merge-commit variant (`{commitType}(slug): merge title` + trailer). **Every merge caller passes a blank commit message on purpose** — `TaskMergeService` fills in `BuildMerge` from the task's commit type and its list's name, which is the only place that knows both. Don't reintroduce a caller-side literal.
|
||||
- **TaskResetService** — discards a failed task's worktree, resets the row to Idle, preserves run history
|
||||
- **AgentFileService** — manages `~/.claudeDo/agents/*.md`; list/refresh via SignalR
|
||||
@@ -202,8 +202,7 @@ launch specs · worktrees · agents/settings/lists · reports/notes/prep · diag
|
||||
- `PrepLine`
|
||||
- `PrepFinished`
|
||||
- `OperationProgress` (generic `(opKey, phase, current, total)` channel; merge phases are its
|
||||
first producer — `IWorkerClient.MergeProgressEvent` on the Ui side is a thin forwarder over it
|
||||
for existing consumers, elapsed seconds riding in the `current` slot)
|
||||
first producer)
|
||||
- `PlanningMergeStarted`
|
||||
- `PlanningSubtaskMerged`
|
||||
- `PlanningMergeConflict`
|
||||
|
||||
+3
-1
@@ -708,7 +708,9 @@ public sealed class ExternalMcpService
|
||||
else
|
||||
{
|
||||
var r = await _merge.ApproveAndMergeAsync(taskId, targetBranch ?? "", leaveConflictsInTree, cancellationToken, progress, skipVerify);
|
||||
if (r.Status == TaskMergeService.StatusBlocked)
|
||||
// dirty_target is a refusal like blocked -- only the UI turns it into a stash
|
||||
// retry; an MCP session must not read it as an approve that went through.
|
||||
if (r.Status is TaskMergeService.StatusBlocked or TaskMergeService.StatusDirtyTarget)
|
||||
throw new InvalidOperationException(r.ErrorMessage ?? "approve failed");
|
||||
mergeStatus = r.Status;
|
||||
mergeConflicts = r.ConflictFiles;
|
||||
|
||||
+17
-6
@@ -7,32 +7,36 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
public sealed record StartListHandlerResult(bool Requested, string ListId, string ListName, int TaskCount, IReadOnlyList<int> TaskNumbers);
|
||||
public sealed record StartListHandlerResult(bool Requested, string ListId, string ListName, int TaskCount, IReadOnlyList<int> TaskNumbers, string? Reason = null);
|
||||
|
||||
// The "Let Claude handle it" button, reachable from outside the UI. The worker only broadcasts;
|
||||
// the App owns the ConPTY tile and creates the handler task exactly as the button does
|
||||
// (MissionControlViewModel.OpenMergeHelperConPtySessionAsync), so there is one code path for
|
||||
// starting a run, not two. Without a connected App nothing happens -- the result says so.
|
||||
// starting a run, not two. Without a connected App the broadcast has nobody to receive it, so
|
||||
// ConnectedClientTracker is checked first and Requested comes back false with a Reason instead of
|
||||
// an unconditional true.
|
||||
[McpServerToolType]
|
||||
public sealed class ListHandlerMcpTools
|
||||
{
|
||||
private readonly ListRepository _lists;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly ConnectedClientTracker _clients;
|
||||
|
||||
public ListHandlerMcpTools(ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster)
|
||||
public ListHandlerMcpTools(ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster, ConnectedClientTracker clients)
|
||||
{
|
||||
_lists = lists;
|
||||
_tasks = tasks;
|
||||
_broadcaster = broadcaster;
|
||||
_clients = clients;
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Start a list-handler run (\"Let Claude handle it\") for a list, exactly like the button in " +
|
||||
"Mission Control: the connected ClaudeDo App creates the handler task and opens the triage " +
|
||||
"ConPTY tile for the given tasks. taskIds defaults to every Idle, non-manual task of the list. " +
|
||||
"Requires the App to be running and connected to this worker -- the worker only broadcasts " +
|
||||
"the request. taskIds accept task numbers (#123 or bare 123).")]
|
||||
"Requires the App to be running and connected to this worker -- if it isn't, Requested comes " +
|
||||
"back false with a reason and nothing is started. taskIds accept task numbers (#123 or bare 123).")]
|
||||
public async Task<StartListHandlerResult> StartListHandler(
|
||||
[Description("The list to run the handler on.")] string listId,
|
||||
[Description("Tasks to hand to the run, in the order to triage them. Omit for all Idle, non-manual tasks of the list.")] IReadOnlyList<string>? taskIds = null,
|
||||
@@ -65,8 +69,15 @@ public sealed class ListHandlerMcpTools
|
||||
if (chosen.Count == 0)
|
||||
throw new InvalidOperationException($"No tasks to hand to the list handler for {list.Name}.");
|
||||
|
||||
var numbers = chosen.Select(t => t.Number).ToList();
|
||||
if (!_clients.AnyConnected)
|
||||
{
|
||||
return new StartListHandlerResult(false, listId, list.Name, chosen.Count, numbers,
|
||||
"No ClaudeDo App is connected to this worker, so the request could not be delivered. Open the app and retry.");
|
||||
}
|
||||
|
||||
var ids = chosen.Select(t => t.Id).ToList();
|
||||
await _broadcaster.ListHandlerStartRequested(listId, ids);
|
||||
return new StartListHandlerResult(true, listId, list.Name, ids.Count, chosen.Select(t => t.Number).ToList());
|
||||
return new StartListHandlerResult(true, listId, list.Name, ids.Count, numbers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace ClaudeDo.Worker.Hub;
|
||||
|
||||
/// <summary>
|
||||
/// Counts SignalR clients (the ClaudeDo App) currently connected to <see cref="WorkerHub"/>.
|
||||
/// A <c>Hub</c> instance is transient per connection, so this lives as a DI singleton and is
|
||||
/// updated from <see cref="WorkerHub.OnConnectedAsync"/>/<see cref="WorkerHub.OnDisconnectedAsync"/>
|
||||
/// — the only way an out-of-band caller (external MCP's start_list_handler, which only
|
||||
/// broadcasts) can tell whether anything is listening before claiming it did something.
|
||||
/// </summary>
|
||||
public sealed class ConnectedClientTracker
|
||||
{
|
||||
private int _count;
|
||||
|
||||
public bool AnyConnected => Volatile.Read(ref _count) > 0;
|
||||
|
||||
public void Increment() => Interlocked.Increment(ref _count);
|
||||
|
||||
public void Decrement() => Interlocked.Decrement(ref _count);
|
||||
}
|
||||
@@ -74,6 +74,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
private readonly TicketImportService _ticketImport;
|
||||
private readonly HandlerRestartCoordinator? _handlerRestart;
|
||||
private readonly HandlerRoundState? _roundState;
|
||||
private readonly ConnectedClientTracker? _clientTracker;
|
||||
|
||||
public WorkerHub(
|
||||
QueueService queue,
|
||||
@@ -114,7 +115,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
TicketClientFactory ticketClients = null!,
|
||||
TicketImportService ticketImport = null!,
|
||||
HandlerRestartCoordinator? handlerRestart = null,
|
||||
HandlerRoundState? roundState = null)
|
||||
HandlerRoundState? roundState = null,
|
||||
ConnectedClientTracker? clientTracker = null)
|
||||
{
|
||||
_queue = queue;
|
||||
_waker = waker;
|
||||
@@ -155,6 +157,21 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
_ticketImport = ticketImport;
|
||||
_handlerRestart = handlerRestart;
|
||||
_roundState = roundState;
|
||||
_clientTracker = clientTracker;
|
||||
}
|
||||
|
||||
// Tracks connected UI clients so an out-of-band caller (external MCP's start_list_handler)
|
||||
// can tell whether the broadcast it just sent had anyone to receive it.
|
||||
public override Task OnConnectedAsync()
|
||||
{
|
||||
_clientTracker?.Increment();
|
||||
return base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
public override Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
_clientTracker?.Decrement();
|
||||
return base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
// Persistence boundary for the session_skills JSON-array columns (task/list/global).
|
||||
@@ -461,7 +478,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
}
|
||||
|
||||
public Task<MergeResultDto> MergeTask(
|
||||
string taskId, string targetBranch, bool removeWorktree, string commitMessage)
|
||||
string taskId, string targetBranch, bool removeWorktree, string commitMessage, bool autostash = false)
|
||||
=> HubGuard(async () =>
|
||||
{
|
||||
// A blank message is handed through deliberately — TaskMergeService builds the
|
||||
@@ -471,7 +488,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
targetBranch ?? "",
|
||||
removeWorktree,
|
||||
commitMessage,
|
||||
CancellationToken.None);
|
||||
leaveConflictsInTree: false,
|
||||
CancellationToken.None,
|
||||
autostash: autostash);
|
||||
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
||||
});
|
||||
|
||||
@@ -666,7 +685,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
if (!result.Ok) throw new HubException(result.Reason ?? "dequeue failed");
|
||||
}
|
||||
|
||||
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch)
|
||||
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch, bool autostash = false)
|
||||
=> HubGuard(async () =>
|
||||
{
|
||||
bool hasChildren;
|
||||
@@ -681,7 +700,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
return new MergeResultDto(unitResult.Status, Array.Empty<string>(), unitResult.Reason);
|
||||
}
|
||||
|
||||
var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None);
|
||||
var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None, autostash);
|
||||
if (r.Status == TaskMergeService.StatusBlocked)
|
||||
throw new HubException(r.ErrorMessage ?? "approve failed");
|
||||
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
||||
@@ -770,12 +789,13 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
// Builds the launch spec for an embedded ConPTY "merge helper" session that drives the given
|
||||
// tasks to a merged/Done state via the mcp__claudedo__* tools. listId scopes the brief label
|
||||
// and cwd to that list. handlerTaskId is the id returned by CreateMergeHelperTask -- it is
|
||||
// rendered into the brief so the session can call handoff_list_handler on itself.
|
||||
public Task<LaunchSpec> GetMergeHelperLaunchSpec(string[] taskIds, string listId, string handlerTaskId) => HubGuard(() =>
|
||||
// rendered into the brief so the session can call handoff_list_handler on itself. model
|
||||
// overrides the triage session's model (null = default opus).
|
||||
public Task<LaunchSpec> GetMergeHelperLaunchSpec(string[] taskIds, string listId, string handlerTaskId, string? model) => HubGuard(() =>
|
||||
{
|
||||
if (_interactiveLaunchSpec is null)
|
||||
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||
return _interactiveLaunchSpec.BuildForMergeHelperAsync(taskIds, listId, handlerTaskId, Context.ConnectionAborted);
|
||||
return _interactiveLaunchSpec.BuildForMergeHelperAsync(taskIds, listId, handlerTaskId, model, Context.ConnectionAborted);
|
||||
});
|
||||
|
||||
// Creates the ClaudeDo task that owns a list-handler run, before the ConPTY tile opens --
|
||||
@@ -1376,6 +1396,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
try
|
||||
{
|
||||
var result = await _ticketImport.ImportAsync(listId, Context.ConnectionAborted);
|
||||
// ListUpdated allein reicht nicht: die UI liest das als "Listen-Metadaten geaendert"
|
||||
// und patcht nur bestehende Zeilen. Neue Tasks tauchen nur ueber TaskUpdated auf, wie
|
||||
// bei jedem anderen out-of-band Creator (add_task und Co.).
|
||||
foreach (var taskId in result.CreatedTaskIds)
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
await _broadcaster.ListUpdated(listId);
|
||||
return new TicketImportResultDto(result.Examined, result.Created);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,10 @@ public sealed class TaskMergeService
|
||||
public const string StatusMergedVerifyPending = "merged_verify_pending";
|
||||
public const string StatusVerifyPassed = "verify_passed";
|
||||
public const string StatusUntrackedCollision = "untracked_collision";
|
||||
// Nothing landed -- the target working dir has uncommitted changes. Its own status rather
|
||||
// than a Blocked reason string so callers can offer the autostash retry (MergeAsync's
|
||||
// autostash: true) without matching on prose.
|
||||
public const string StatusDirtyTarget = "dirty_target";
|
||||
|
||||
public const string StatusReverted = "reverted";
|
||||
public const string StatusConflictAborted = "conflict_aborted";
|
||||
@@ -375,7 +379,8 @@ public sealed class TaskMergeService
|
||||
bool leaveConflictsInTree,
|
||||
CancellationToken ct,
|
||||
IProgress<ProgressNotificationValue>? progress = null,
|
||||
bool skipVerify = false)
|
||||
bool skipVerify = false,
|
||||
bool autostash = false)
|
||||
{
|
||||
var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct);
|
||||
|
||||
@@ -400,8 +405,10 @@ public sealed class TaskMergeService
|
||||
return Blocked("working directory is not a git repository");
|
||||
if (await _git.IsMidMergeAsync(list.WorkingDir, ct))
|
||||
return Blocked("target working directory is mid-merge");
|
||||
if (await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct))
|
||||
return Blocked("target working tree has uncommitted changes");
|
||||
// autostash hands the dirty tree to git itself (merge --autostash) instead of refusing.
|
||||
if (!autostash && await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct))
|
||||
return new MergeResult(StatusDirtyTarget, Array.Empty<string>(),
|
||||
"target working tree has uncommitted changes");
|
||||
|
||||
var currentBranch = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
|
||||
if (!string.Equals(currentBranch, targetBranch, StringComparison.Ordinal))
|
||||
@@ -417,7 +424,7 @@ public sealed class TaskMergeService
|
||||
var message = string.IsNullOrWhiteSpace(commitMessage)
|
||||
? DefaultMergeMessage(task, list)
|
||||
: commitMessage;
|
||||
var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, message, ct);
|
||||
var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, message, ct, autostash);
|
||||
if (exitCode != 0)
|
||||
{
|
||||
List<string> files;
|
||||
@@ -973,13 +980,15 @@ public sealed class TaskMergeService
|
||||
}
|
||||
}
|
||||
|
||||
public Task<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct)
|
||||
=> ApproveAndMergeAsync(taskId, targetBranch, leaveConflictsInTree: false, ct);
|
||||
public Task<MergeResult> ApproveAndMergeAsync(
|
||||
string taskId, string targetBranch, CancellationToken ct, bool autostash = false)
|
||||
=> ApproveAndMergeAsync(taskId, targetBranch, leaveConflictsInTree: false, ct, autostash: autostash);
|
||||
|
||||
public async Task<MergeResult> ApproveAndMergeAsync(
|
||||
string taskId, string targetBranch, bool leaveConflictsInTree, CancellationToken ct,
|
||||
IProgress<ProgressNotificationValue>? progress = null,
|
||||
bool skipVerify = false)
|
||||
bool skipVerify = false,
|
||||
bool autostash = false)
|
||||
{
|
||||
var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct);
|
||||
|
||||
@@ -1070,7 +1079,7 @@ public sealed class TaskMergeService
|
||||
// MergeAsync transitions the task WaitingForReview -> Done on a successful merge.
|
||||
// Remove the worktree on approve (matching the unit-merge path) so merged
|
||||
// worktrees don't pile up; the merge commit on the target branch is the record.
|
||||
return await MergeAsync(taskId, target, removeWorktree: true, commitMessage: "", leaveConflictsInTree, ct, progress, skipVerify);
|
||||
return await MergeAsync(taskId, target, removeWorktree: true, commitMessage: "", leaveConflictsInTree, ct, progress, skipVerify, autostash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace ClaudeDo.Worker.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Formats the Fatal log line for a startup/bind failure (e.g. a port already in use) so
|
||||
/// Program.cs can log a diagnosable message and exit cleanly instead of crashing unhandled.
|
||||
/// </summary>
|
||||
public static class WorkerStartupFailure
|
||||
{
|
||||
public const int ExitCode = 1;
|
||||
|
||||
public static string FormatBindFailureMessage(Exception ex, int signalRPort, int externalMcpPort)
|
||||
{
|
||||
var ports = externalMcpPort > 0
|
||||
? $"ports {signalRPort} and {externalMcpPort}"
|
||||
: $"port {signalRPort}";
|
||||
return $"ClaudeDo.Worker failed to start on {ports}: {ex.Message} " +
|
||||
"Is another worker instance already running, or is another process using this port?";
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,11 @@ using ClaudeDo.Worker.Worktrees;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Serilog;
|
||||
|
||||
// Single-instance per user session. Multiple launch paths exist (logon task,
|
||||
// app ensure-running, Restart button); a second instance exits cleanly instead
|
||||
// of fighting over the SignalR port.
|
||||
var mutex = new Mutex(true, @"Local\ClaudeDoWorker", out var createdNew);
|
||||
// Single instance machine-wide. Multiple launch paths exist (logon task, app
|
||||
// ensure-running, Restart button) and a Remote Desktop / Fast User Switching session
|
||||
// gets its own `Local\` namespace, so `Global\` is required or a second session's
|
||||
// worker silently fights the first one over the SignalR port instead of exiting.
|
||||
var mutex = new Mutex(true, @"Global\ClaudeDoWorker", out var createdNew);
|
||||
if (!createdNew)
|
||||
return; // another instance already owns the port; exit 0
|
||||
|
||||
@@ -65,6 +66,14 @@ builder.Host.UseSerilog((ctx, lc) => lc
|
||||
.WriteTo.Sink(broadcastSink));
|
||||
builder.Services.AddSingleton(logBuffer);
|
||||
|
||||
// Last-resort net: any exception that misses every other handler (e.g. a crash on a
|
||||
// background thread) still gets one Fatal line in the log before the process dies.
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
|
||||
{
|
||||
Log.Fatal(e.ExceptionObject as Exception, "Unhandled exception, terminating (IsTerminating: {IsTerminating})", e.IsTerminating);
|
||||
Log.CloseAndFlush();
|
||||
};
|
||||
|
||||
builder.Services.AddDbContextFactory<ClaudeDoDbContext>(opt =>
|
||||
opt.UseSqlite($"Data Source={cfg.DbPath}"));
|
||||
|
||||
@@ -90,6 +99,7 @@ builder.Services.AddSignalR(options => options.MaximumParallelInvocationsPerClie
|
||||
// Runner stack.
|
||||
builder.Services.AddSingleton<IClaudeProcess, ClaudeProcess>();
|
||||
builder.Services.AddSingleton<HubBroadcaster>();
|
||||
builder.Services.AddSingleton<ConnectedClientTracker>();
|
||||
builder.Services.AddSingleton<GitService>();
|
||||
builder.Services.AddSingleton<WorktreeManager>();
|
||||
builder.Services.AddSingleton<ClaudeArgsBuilder>();
|
||||
@@ -328,6 +338,7 @@ if (cfg.ExternalMcpPort > 0)
|
||||
|
||||
externalBuilder.Services.AddSingleton(cfg);
|
||||
Share<HubBroadcaster>();
|
||||
Share<ConnectedClientTracker>();
|
||||
Share<QueueService>();
|
||||
Share<OverrideSlotService>();
|
||||
Share<IDbContextFactory<ClaudeDoDbContext>>();
|
||||
@@ -404,13 +415,24 @@ if (cfg.ExternalMcpPort > 0)
|
||||
string.IsNullOrEmpty(cfg.ExternalMcpApiKey) ? "loopback-only" : "X-ClaudeDo-Key");
|
||||
}
|
||||
|
||||
if (externalApp is null)
|
||||
try
|
||||
{
|
||||
await app.RunAsync();
|
||||
if (externalApp is null)
|
||||
{
|
||||
await app.RunAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await Task.WhenAll(app.RunAsync(), externalApp.RunAsync());
|
||||
}
|
||||
}
|
||||
else
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Task.WhenAll(app.RunAsync(), externalApp.RunAsync());
|
||||
// Most commonly a Kestrel bind failure (port already in use by a second session's
|
||||
// worker or an unrelated process) — log it plainly instead of an unhandled crash.
|
||||
Log.Fatal(ex, "{Message}", WorkerStartupFailure.FormatBindFailureMessage(ex, cfg.SignalRPort, cfg.ExternalMcpPort));
|
||||
Log.CloseAndFlush();
|
||||
Environment.Exit(WorkerStartupFailure.ExitCode);
|
||||
}
|
||||
|
||||
GC.KeepAlive(mutex);
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace ClaudeDo.Worker.Report;
|
||||
|
||||
public sealed class WeekReportService
|
||||
{
|
||||
private static readonly string[] DefaultExcludes = { @"C:\Private" };
|
||||
private static readonly string[] DefaultExcludes = Array.Empty<string>();
|
||||
private const string NoActivity = "_No activity in this period._";
|
||||
|
||||
private readonly IClaudeHistoryReader _reader;
|
||||
@@ -72,7 +72,7 @@ public sealed class WeekReportService
|
||||
IReadOnlyList<string> args =
|
||||
[
|
||||
"-p", "--output-format", "stream-json", "--verbose",
|
||||
"--permission-mode", "auto",
|
||||
"--permission-mode", PermissionModeResolver.Resolve(safeModel, "auto"),
|
||||
"--model", safeModel,
|
||||
];
|
||||
var result = await _claude.RunAsync(args, prompt, Path.GetTempPath(), _ => Task.CompletedTask, ct);
|
||||
|
||||
@@ -290,8 +290,10 @@ public sealed class InteractiveLaunchSpecService
|
||||
/// handlerTaskId (the id returned by CreateMergeHelperTaskAsync) is rendered into the brief so
|
||||
/// the session can call handoff_list_handler/submit_task_for_review on its own handler task.
|
||||
/// Throws KeyNotFoundException if the list doesn't exist; InvalidOperationException if
|
||||
/// taskIds is empty or the list has no existing working directory.</summary>
|
||||
public async Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct)
|
||||
/// taskIds is empty or the list has no existing working directory.
|
||||
/// model overrides the triage session's model (start-modal dropdown); null/unknown falls
|
||||
/// back to <see cref="ModelRegistry.HandlerTriageAlias"/>. Handoff phases are unaffected.</summary>
|
||||
public async Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model, CancellationToken ct)
|
||||
{
|
||||
if (taskIds.Count == 0)
|
||||
throw new InvalidOperationException("No tasks selected for the list handler.");
|
||||
@@ -344,12 +346,13 @@ public sealed class InteractiveLaunchSpecService
|
||||
// longer influences a handler session at all, it would otherwise apply the LIST's effort
|
||||
// preset to a completely different model.
|
||||
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
|
||||
var triageModel = ModelRegistry.TryNormalizeAlias(model) ?? ModelRegistry.HandlerTriageAlias;
|
||||
|
||||
var args = new List<string>
|
||||
{
|
||||
"--model", ModelRegistry.HandlerTriageAlias,
|
||||
"--effort", EffortFor(settings, ModelRegistry.HandlerTriageAlias),
|
||||
"--permission-mode", PermissionModeResolver.Resolve(ModelRegistry.HandlerTriageAlias, "auto"),
|
||||
"--model", triageModel,
|
||||
"--effort", EffortFor(settings, triageModel),
|
||||
"--permission-mode", PermissionModeResolver.Resolve(triageModel, "auto"),
|
||||
"--allowedTools", MergeHelperAllowedTools,
|
||||
"--add-dir", sessionDir, repoDir,
|
||||
"--append-system-prompt-file", systemPromptPath,
|
||||
|
||||
@@ -553,8 +553,6 @@ public sealed class TaskRunner
|
||||
var transition = await _state.SubmitForChildrenAsync(task.Id, finishedAt, reviewResult, CancellationToken.None);
|
||||
if (!transition.Ok)
|
||||
_logger.LogWarning("SubmitForChildrenAsync rejected for task {TaskId}: {Reason}", task.Id, transition.Reason);
|
||||
foreach (var child in pendingChildren)
|
||||
await _state.EnqueueAsync(child.Id, CancellationToken.None);
|
||||
await _broadcaster.WorkerLog(
|
||||
$"Finished #{task.Number} \"{task.Title}\" (waiting on {pendingChildren.Count} improvement(s))",
|
||||
WorkerLogLevel.Success, DateTime.UtcNow);
|
||||
@@ -791,10 +789,10 @@ public sealed class TaskRunner
|
||||
switch (result.TerminalReason)
|
||||
{
|
||||
case "max_turns":
|
||||
var ofConfigured = configuredMaxTurns is int m ? $" von {m}" : "";
|
||||
return $"Turn-Budget erschöpft ({result.TurnCount}{ofConfigured} Turns). "
|
||||
+ "Erhöhe `maxTurns` am Task (`set_task_config`) und stelle den Task erneut in die Queue "
|
||||
+ "– der Worktree bleibt erhalten.";
|
||||
var ofConfigured = configuredMaxTurns is int m ? $" of {m}" : "";
|
||||
return $"Turn budget exhausted ({result.TurnCount}{ofConfigured} turns). "
|
||||
+ "Raise `maxTurns` on the task (`set_task_config`) and re-queue it "
|
||||
+ "- the worktree is kept.";
|
||||
|
||||
case "api_error":
|
||||
if (!string.IsNullOrWhiteSpace(result.ResultMarkdown)) return result.ResultMarkdown;
|
||||
|
||||
@@ -159,16 +159,40 @@ public sealed class TaskStateService : ITaskStateService
|
||||
|
||||
public async Task<TransitionResult> SubmitForChildrenAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var affected = await ctx.Tasks
|
||||
.Where(t => t.Id == taskId && t.Status == TaskStatus.Running)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.WaitingForChildren)
|
||||
.SetProperty(t => t.FinishedAt, finishedAt)
|
||||
.SetProperty(t => t.Result, result), ct);
|
||||
PlanningPhase planningPhase;
|
||||
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
||||
{
|
||||
var affected = await ctx.Tasks
|
||||
.Where(t => t.Id == taskId && t.Status == TaskStatus.Running)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.WaitingForChildren)
|
||||
.SetProperty(t => t.FinishedAt, finishedAt)
|
||||
.SetProperty(t => t.Result, result), ct);
|
||||
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task not running; cannot submit for children.");
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task not running; cannot submit for children.");
|
||||
|
||||
planningPhase = await ctx.Tasks.AsNoTracking()
|
||||
.Where(t => t.Id == taskId)
|
||||
.Select(t => t.PlanningPhase)
|
||||
.FirstAsync(ct);
|
||||
}
|
||||
|
||||
// A normal (non-planning) parent queues its own children as a sequential chain right
|
||||
// here; a planning parent's children wait for the user's explicit "Queue plan" action
|
||||
// (PlanningChainCoordinator.QueuePlanAsync), so leave those untouched. A child that's
|
||||
// already Running makes SetupChainAsync throw — log it rather than fail this transition.
|
||||
if (planningPhase == PlanningPhase.None)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _chain.SetupChainAsync(taskId, enqueue: true, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "SetupChainAsync failed for parent {TaskId}", taskId);
|
||||
}
|
||||
}
|
||||
|
||||
await NotifyAsync(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
|
||||
@@ -6,7 +6,7 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tickets;
|
||||
|
||||
public sealed record TicketImportResult(int Examined, int Created);
|
||||
public sealed record TicketImportResult(int Examined, int Created, IReadOnlyList<string> CreatedTaskIds);
|
||||
|
||||
/// <summary>
|
||||
/// Holt das Board des verknüpften Ticket-Projekts und legt für jedes offene, dem Token-Inhaber
|
||||
@@ -61,16 +61,17 @@ public sealed class TicketImportService
|
||||
var knownSet = known.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var repo = new TaskRepository(ctx);
|
||||
var created = 0;
|
||||
var created = new List<string>();
|
||||
|
||||
foreach (var ticket in mine)
|
||||
{
|
||||
var reference = $"bandel:{ticket.Id}";
|
||||
if (!knownSet.Add(reference)) continue;
|
||||
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await repo.AddAsync(new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Id = taskId,
|
||||
ListId = listId,
|
||||
Title = ticket.Title,
|
||||
Description = BuildDescription(ticket),
|
||||
@@ -79,14 +80,14 @@ public sealed class TicketImportService
|
||||
TicketRef = reference,
|
||||
CreatedBy = "ticketsystem",
|
||||
}, ct);
|
||||
created++;
|
||||
created.Add(taskId);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Ticket-Import für Liste {list_id}: {examined} geprüft, {created} neue Tasks",
|
||||
listId, board.Count, created);
|
||||
listId, board.Count, created.Count);
|
||||
|
||||
return new TicketImportResult(board.Count, created);
|
||||
return new TicketImportResult(board.Count, created.Count, created);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Data.Tests;
|
||||
|
||||
public sealed class ListFolderTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
private readonly DbContextOptions<ClaudeDoDbContext> _options;
|
||||
|
||||
public ListFolderTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_folders_{Guid.NewGuid():N}.db");
|
||||
_options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||
.UseSqlite($"Data Source={_dbPath}")
|
||||
.Options;
|
||||
|
||||
using var ctx = new ClaudeDoDbContext(_options);
|
||||
ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var suffix in new[] { "", "-wal", "-shm" })
|
||||
try { File.Delete(_dbPath + suffix); } catch { }
|
||||
}
|
||||
|
||||
private ClaudeDoDbContext Open() => new(_options);
|
||||
|
||||
private static ListFolderEntity NewFolder(string id, string name = "Group") =>
|
||||
new() { Id = id, Name = name, CreatedAt = DateTime.UtcNow };
|
||||
|
||||
[Fact]
|
||||
public async Task Deleting_a_folder_keeps_its_lists_and_moves_them_to_the_top_level()
|
||||
{
|
||||
await using (var ctx = Open())
|
||||
{
|
||||
var repo = new ListRepository(ctx);
|
||||
await repo.AddFolderAsync(NewFolder("f1"));
|
||||
await repo.AddAsync(new ListEntity { Id = "l1", Name = "Inside", CreatedAt = DateTime.UtcNow, FolderId = "f1" });
|
||||
}
|
||||
|
||||
await using (var ctx = Open())
|
||||
await new ListRepository(ctx).DeleteFolderAsync("f1");
|
||||
|
||||
await using (var ctx = Open())
|
||||
{
|
||||
var list = await ctx.Lists.AsNoTracking().FirstAsync(l => l.Id == "l1");
|
||||
Assert.Null(list.FolderId);
|
||||
Assert.Empty(await ctx.ListFolders.AsNoTracking().ToListAsync());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenameFolderAsync_keeps_sort_order_and_collapse_state()
|
||||
{
|
||||
// The whole reason it patches instead of Update()-ing a rebuilt entity: a rename must not
|
||||
// silently unfold the folder or shuffle it to the top.
|
||||
await using (var ctx = Open())
|
||||
{
|
||||
var repo = new ListRepository(ctx);
|
||||
await repo.AddFolderAsync(NewFolder("f1", "First"));
|
||||
await repo.AddFolderAsync(NewFolder("f2", "Second"));
|
||||
await repo.SetFolderCollapsedAsync("f2", true);
|
||||
}
|
||||
|
||||
await using (var ctx = Open())
|
||||
await new ListRepository(ctx).RenameFolderAsync("f2", "Renamed", "Star");
|
||||
|
||||
await using (var ctx = Open())
|
||||
{
|
||||
var folder = await ctx.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f2");
|
||||
Assert.Equal("Renamed", folder.Name);
|
||||
Assert.Equal("Star", folder.Icon);
|
||||
Assert.True(folder.IsCollapsed);
|
||||
Assert.Equal(1, folder.SortOrder);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetListFolderAsync_moves_a_list_in_and_back_out()
|
||||
{
|
||||
await using (var ctx = Open())
|
||||
{
|
||||
var repo = new ListRepository(ctx);
|
||||
await repo.AddFolderAsync(NewFolder("f1"));
|
||||
await repo.AddAsync(new ListEntity { Id = "l1", Name = "Roaming", CreatedAt = DateTime.UtcNow });
|
||||
}
|
||||
|
||||
await using (var ctx = Open())
|
||||
await new ListRepository(ctx).SetListFolderAsync("l1", "f1");
|
||||
|
||||
await using (var ctx = Open())
|
||||
Assert.Equal("f1", (await ctx.Lists.AsNoTracking().FirstAsync(l => l.Id == "l1")).FolderId);
|
||||
|
||||
await using (var ctx = Open())
|
||||
await new ListRepository(ctx).SetListFolderAsync("l1", null);
|
||||
|
||||
await using (var ctx = Open())
|
||||
Assert.Null((await ctx.Lists.AsNoTracking().FirstAsync(l => l.Id == "l1")).FolderId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSidebarAsync_numbers_folders_and_lists_in_one_sequence()
|
||||
{
|
||||
await using var ctx = Open();
|
||||
var repo = new ListRepository(ctx);
|
||||
await repo.AddFolderAsync(NewFolder("f1"));
|
||||
await repo.AddAsync(new ListEntity { Id = "inside", Name = "Inside", CreatedAt = DateTime.UtcNow, FolderId = "f1" });
|
||||
await repo.AddAsync(new ListEntity { Id = "top", Name = "Top", CreatedAt = DateTime.UtcNow });
|
||||
|
||||
// Sidebar: the top-level list above the folder, whose one child follows the folder.
|
||||
await repo.ReorderSidebarAsync([("top", false), ("f1", true), ("inside", false)]);
|
||||
|
||||
Assert.Equal(0, (await repo.GetByIdAsync("top"))!.SortOrder);
|
||||
Assert.Equal(1, (await repo.GetFoldersAsync()).Single().SortOrder);
|
||||
Assert.Equal(2, (await repo.GetByIdAsync("inside"))!.SortOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAsync_appends_past_the_folders_too()
|
||||
{
|
||||
await using var ctx = Open();
|
||||
var repo = new ListRepository(ctx);
|
||||
await repo.AddFolderAsync(NewFolder("f1"));
|
||||
await repo.AddFolderAsync(NewFolder("f2"));
|
||||
await repo.AddAsync(new ListEntity { Id = "l1", Name = "Fresh", CreatedAt = DateTime.UtcNow });
|
||||
|
||||
// 2, not 0 — a new list must not jump above the existing folders on the next load.
|
||||
Assert.Equal(2, (await repo.GetByIdAsync("l1"))!.SortOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddFolderAsync_appends_in_creation_order()
|
||||
{
|
||||
await using var ctx = Open();
|
||||
var repo = new ListRepository(ctx);
|
||||
await repo.AddFolderAsync(NewFolder("f1", "One"));
|
||||
await repo.AddFolderAsync(NewFolder("f2", "Two"));
|
||||
|
||||
var folders = await repo.GetFoldersAsync();
|
||||
Assert.Equal(["One", "Two"], folders.Select(f => f.Name));
|
||||
Assert.Equal([0, 1], folders.Select(f => f.SortOrder));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClaudeDo.Localization.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// JsonDocument/JsonElement silently keep the last value on a duplicate key instead of throwing,
|
||||
/// so a repeated top-level block (e.g. two "checks" objects) is invisible to JSON-validity and
|
||||
/// parity tests alike. This walks the raw token stream instead.
|
||||
/// </summary>
|
||||
public class DuplicateLocaleKeysTests
|
||||
{
|
||||
private static string LocalesDir()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (dir is not null)
|
||||
{
|
||||
var candidate = Path.Combine(dir, "src", "ClaudeDo.Localization", "locales");
|
||||
if (Directory.Exists(candidate)) return candidate;
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
}
|
||||
throw new DirectoryNotFoundException("Could not locate src/ClaudeDo.Localization/locales");
|
||||
}
|
||||
|
||||
private static List<string> FindDuplicateTopLevelKeys(string json)
|
||||
{
|
||||
var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json));
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
var duplicates = new List<string>();
|
||||
var depth = 0;
|
||||
|
||||
while (reader.Read())
|
||||
{
|
||||
switch (reader.TokenType)
|
||||
{
|
||||
case JsonTokenType.StartObject:
|
||||
case JsonTokenType.StartArray:
|
||||
depth++;
|
||||
break;
|
||||
case JsonTokenType.EndObject:
|
||||
case JsonTokenType.EndArray:
|
||||
depth--;
|
||||
break;
|
||||
case JsonTokenType.PropertyName:
|
||||
if (depth == 1)
|
||||
{
|
||||
var name = reader.GetString()!;
|
||||
if (!seen.Add(name)) duplicates.Add(name);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return duplicates;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detects_duplicate_top_level_key_in_fixture()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"checks": { "a": "1" },
|
||||
"other": { "b": "2" },
|
||||
"checks": { "c": "3" }
|
||||
}
|
||||
""";
|
||||
|
||||
var duplicates = FindDuplicateTopLevelKeys(json);
|
||||
|
||||
Assert.Contains("checks", duplicates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Locale_files_have_no_duplicate_top_level_keys()
|
||||
{
|
||||
var localesDir = LocalesDir();
|
||||
foreach (var path in Directory.EnumerateFiles(localesDir, "*.json"))
|
||||
{
|
||||
var duplicates = FindDuplicateTopLevelKeys(File.ReadAllText(path));
|
||||
Assert.True(duplicates.Count == 0,
|
||||
$"{Path.GetFileName(path)} has duplicate top-level keys: {string.Join(", ", duplicates)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests;
|
||||
|
||||
// Covers the "restart worker" footer feedback fix: RestartWorkerStatus used to be set but was
|
||||
// never bound in any view, so a died-on-launch worker (e.g. a port conflict, see #292) restarted
|
||||
// silently. RestartWorkerOperation is a test seam standing in for the real kill-and-relaunch +
|
||||
// short-timeout survival check, so these tests only assert the success/failure routing to the
|
||||
// shared footer strip (WorkerLogText/WorkerLogLevel), not real process spawning.
|
||||
public class IslandsShellViewModelRestartWorkerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ProcessSurvives_FlashesFooterSuccess()
|
||||
{
|
||||
var vm = new IslandsShellViewModel { RestartWorkerOperation = () => Task.CompletedTask };
|
||||
|
||||
await vm.RestartWorkerCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(WorkerLogLevel.Success, vm.WorkerLogLevel);
|
||||
Assert.Contains("restarted", vm.WorkerLogText);
|
||||
Assert.True(vm.IsWorkerLogVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessDiesWithinTimeout_FlashesFooterError()
|
||||
{
|
||||
var vm = new IslandsShellViewModel
|
||||
{
|
||||
RestartWorkerOperation = () => throw new InvalidOperationException("worker process exited immediately (code 1)")
|
||||
};
|
||||
|
||||
await vm.RestartWorkerCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(WorkerLogLevel.Error, vm.WorkerLogLevel);
|
||||
Assert.Contains("worker process exited immediately", vm.WorkerLogText);
|
||||
Assert.True(vm.IsWorkerLogVisible);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.ViewModels.Conflicts;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests;
|
||||
|
||||
// Covers the worker-menu "Shutdown" command: a confirm dialog gates the actual kill-worker +
|
||||
// close-app action. ShutdownOperation is a test seam standing in for the real body, so these
|
||||
// tests only assert the confirm gate, never a real process kill.
|
||||
public class IslandsShellViewModelShutdownTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Declined_DoesNotInvokeShutdownOperation()
|
||||
{
|
||||
var invoked = false;
|
||||
var vm = new IslandsShellViewModel
|
||||
{
|
||||
Dialogs = new FakeDialogService(confirmResult: false),
|
||||
ShutdownOperation = () => invoked = true,
|
||||
};
|
||||
|
||||
await vm.ShutdownCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.False(invoked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Confirmed_InvokesShutdownOperation()
|
||||
{
|
||||
var invoked = false;
|
||||
var vm = new IslandsShellViewModel
|
||||
{
|
||||
Dialogs = new FakeDialogService(confirmResult: true),
|
||||
ShutdownOperation = () => invoked = true,
|
||||
};
|
||||
|
||||
await vm.ShutdownCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.True(invoked);
|
||||
}
|
||||
|
||||
private sealed class FakeDialogService(bool confirmResult) : IDialogService
|
||||
{
|
||||
public Task<bool> ConfirmAsync(string message) => Task.FromResult(confirmResult);
|
||||
|
||||
public Task ShowAboutAsync(AboutModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowWeeklyReportAsync(WeeklyReportModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowUsageMonitorAsync(UsageMonitorModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowSettingsAsync(SettingsModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowListSettingsAsync(ListSettingsModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowRepoImportAsync(RepoImportModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowWorktreesOverviewAsync(WorktreesOverviewModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task<IReadOnlyList<string>?> ShowMergeHelperSelectionAsync(MergeHelperSelectionModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowWorkerConnectionAsync(WorkerConnectionModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowConflictResolverAsync(ConflictResolverViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowLogVisualizerAsync(LogVisualizerViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowErrorAsync(string message) => Task.CompletedTask;
|
||||
public void ShowMissionControl(MissionControlViewModel vm) => throw new NotImplementedException();
|
||||
public void ShowCheatSheets(CheatSheetsViewModel vm) => throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests;
|
||||
|
||||
public class IslandsShellViewModelUpdateClaudeCliTests
|
||||
{
|
||||
[Fact]
|
||||
public void Success_uses_last_non_empty_output_line()
|
||||
{
|
||||
var (success, message) = IslandsShellViewModel.SummarizeClaudeUpdateOutcome(
|
||||
0, "Checking for updates...\nSuccessfully updated to 2.1.4\n", "");
|
||||
|
||||
Assert.True(success);
|
||||
Assert.Equal("Successfully updated to 2.1.4", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Success_with_no_output_falls_back_to_default_message()
|
||||
{
|
||||
var (success, message) = IslandsShellViewModel.SummarizeClaudeUpdateOutcome(0, "", "");
|
||||
|
||||
Assert.True(success);
|
||||
Assert.Equal("Claude CLI is already up to date.", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_reports_exit_code_and_last_stderr_line()
|
||||
{
|
||||
var (success, message) = IslandsShellViewModel.SummarizeClaudeUpdateOutcome(
|
||||
1, "", "network error\nrequest failed\n");
|
||||
|
||||
Assert.False(success);
|
||||
Assert.Equal("Update failed (exit 1): request failed", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_falls_back_to_stdout_when_stderr_is_empty()
|
||||
{
|
||||
var (success, message) = IslandsShellViewModel.SummarizeClaudeUpdateOutcome(
|
||||
2, "some diagnostic line\n", "");
|
||||
|
||||
Assert.False(success);
|
||||
Assert.Equal("Update failed (exit 2): some diagnostic line", message);
|
||||
}
|
||||
}
|
||||
@@ -35,4 +35,14 @@ public class PtyTerminalSessionTests
|
||||
|
||||
Assert.Equal(0, gate.CurrentCount); // acquired
|
||||
}
|
||||
|
||||
// Fix: PasteAsync used to be void and swallow every failure. Not-started is the cheap case to
|
||||
// cover without a real TerminalControl/ConPTY spawn.
|
||||
[Fact]
|
||||
public async Task PasteAsync_ReturnsFalse_WhenNotStarted()
|
||||
{
|
||||
using var session = new PtyTerminalSession();
|
||||
|
||||
Assert.False(await session.PasteAsync());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public event Action<string>? RefineStartedEvent;
|
||||
public event Action<string, bool, string?>? RefineFinishedEvent;
|
||||
public event Action<string, string, int, int>? OperationProgressEvent;
|
||||
public event Action<string, string, int>? MergeProgressEvent;
|
||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
|
||||
@@ -64,7 +63,6 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven)
|
||||
=> PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven);
|
||||
public void RaiseOperationProgress(string opKey, string phase, int current, int total) => OperationProgressEvent?.Invoke(opKey, phase, current, total);
|
||||
public void RaiseMergeProgress(string taskId, string phase, int elapsedSeconds) => MergeProgressEvent?.Invoke(taskId, phase, elapsedSeconds);
|
||||
public void RaisePlanningMergeStarted(string planningTaskId, string targetBranch) => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch);
|
||||
public void RaisePlanningMergeAborted(string planningTaskId, string? reason = null) => PlanningMergeAbortedEvent?.Invoke(planningTaskId, reason);
|
||||
public void RaisePlanningCompleted(string planningTaskId) => PlanningCompletedEvent?.Invoke(planningTaskId);
|
||||
@@ -111,9 +109,9 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public virtual Task SetTaskDoneAsync(string taskId) => Task.CompletedTask;
|
||||
public virtual Task UnsetTaskDoneAsync(string taskId) => Task.CompletedTask;
|
||||
public virtual Task DequeueTaskAsync(string taskId) => Task.CompletedTask;
|
||||
public virtual Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
|
||||
public virtual Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) => Task.FromResult<MergeResultDto?>(null);
|
||||
public virtual Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
|
||||
public virtual Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||
public virtual Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage, bool autostash = false) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||
public virtual Task RejectReviewToQueueAsync(string taskId, string feedback) => Task.CompletedTask;
|
||||
public virtual Task RejectReviewToIdleAsync(string taskId) => Task.CompletedTask;
|
||||
public virtual Task CancelReviewAsync(string taskId) => Task.CompletedTask;
|
||||
@@ -128,7 +126,7 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
||||
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct = default)
|
||||
public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
|
||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public virtual Task<string> CreateMergeHelperTaskAsync(
|
||||
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
|
||||
|
||||
@@ -41,7 +41,7 @@ public class DetailsIslandConflictSeamTests : IDisposable
|
||||
private sealed class ConflictApproveWorkerClient : StubWorkerClient
|
||||
{
|
||||
public override bool IsConnected => true;
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) =>
|
||||
Task.FromResult<MergeResultDto?>(new MergeResultDto("conflict", new[] { "a.cs" }, null));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
// Usability-Audit 2026-09-10: the detail pane showed nothing when a task was blocked on a
|
||||
// predecessor -- the header bar now renders a "Waiting on #N" line bound straight to the same
|
||||
// Task.IsWaiting/DependencyWaitLabel TasksIslandViewModel already computes (no new VM logic here).
|
||||
public class DetailsIslandDependencyWaitTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
|
||||
public DetailsIslandDependencyWaitTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_dep_wait_test_{Guid.NewGuid():N}.db");
|
||||
using var ctx = NewContext();
|
||||
ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { File.Delete(_dbPath); } catch { }
|
||||
try { File.Delete(_dbPath + "-wal"); } catch { }
|
||||
try { File.Delete(_dbPath + "-shm"); } catch { }
|
||||
}
|
||||
|
||||
private ClaudeDoDbContext NewContext()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||
.UseSqlite($"Data Source={_dbPath}")
|
||||
.Options;
|
||||
return new ClaudeDoDbContext(opts);
|
||||
}
|
||||
|
||||
private DetailsIslandViewModel NewVm()
|
||||
{
|
||||
var factory = new TestDbFactory(NewContext);
|
||||
return new DetailsIslandViewModel(factory, new DefaultStub(), new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnblockedTask_HasNoWaitingLine()
|
||||
{
|
||||
var vm = NewVm();
|
||||
vm.Bind(new TaskRowViewModel { Id = Guid.NewGuid().ToString("N"), Number = 1, Status = TaskStatus.Queued });
|
||||
|
||||
Assert.False(vm.Task!.IsWaiting);
|
||||
Assert.Null(vm.Task.DependencyWaitLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlockedTask_ExposesWaitingLineViaBoundRow()
|
||||
{
|
||||
var vm = NewVm();
|
||||
var row = new TaskRowViewModel
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Number = 2,
|
||||
Status = TaskStatus.Queued,
|
||||
IsBlockedByDependency = true,
|
||||
DependencyWaitLabel = "#42",
|
||||
};
|
||||
|
||||
vm.Bind(row);
|
||||
|
||||
Assert.True(vm.Task!.IsWaiting);
|
||||
Assert.Equal("#42", vm.Task.DependencyWaitLabel);
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ public class DetailsIslandPlanningTests : IDisposable
|
||||
private sealed class ThrowingReviewWorkerClient : StubWorkerClient
|
||||
{
|
||||
public override bool IsConnected => true;
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) =>
|
||||
Task.FromException<MergeResultDto?>(new InvalidOperationException("Task is not waiting for review; cannot approve."));
|
||||
}
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ public class DetailsIslandReviewActionsTests : IDisposable
|
||||
{
|
||||
public override bool IsConnected => true;
|
||||
public string ExceptionMessage { get; init; } = "blocked: target working tree has uncommitted changes";
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) =>
|
||||
throw new Exception(ExceptionMessage);
|
||||
}
|
||||
|
||||
@@ -167,15 +167,7 @@ public class DetailsIslandReviewActionsTests : IDisposable
|
||||
{
|
||||
public override bool IsConnected => true;
|
||||
public TaskCompletionSource<MergeResultDto?> Gate { get; } = new();
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Gate.Task;
|
||||
}
|
||||
|
||||
private static int MergeProgressSubscriberCount(StubWorkerClient worker)
|
||||
{
|
||||
var field = typeof(StubWorkerClient).GetField("MergeProgressEvent",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
var del = (Delegate?)field!.GetValue(worker);
|
||||
return del?.GetInvocationList().Length ?? 0;
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) => Gate.Task;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -209,10 +201,10 @@ public class DetailsIslandReviewActionsTests : IDisposable
|
||||
var approve = vm.ApproveReviewCommand.ExecuteAsync(null);
|
||||
var merging = vm.ApproveOp.Label;
|
||||
|
||||
worker.RaiseMergeProgress("some-other-task", "verifying", 30);
|
||||
worker.RaiseOperationProgress("some-other-task", "verifying", 30, 0);
|
||||
Assert.Equal(merging, vm.ApproveOp.Label);
|
||||
|
||||
worker.RaiseMergeProgress("task-approve-2", "verifying", 30);
|
||||
worker.RaiseOperationProgress("task-approve-2", "verifying", 30, 0);
|
||||
Assert.NotEqual(merging, vm.ApproveOp.Label);
|
||||
|
||||
worker.Gate.SetResult(new MergeResultDto("merged", new List<string>(), null));
|
||||
@@ -220,7 +212,7 @@ public class DetailsIslandReviewActionsTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveReview_UnsubscribesFromMergeProgress_AfterTheCallCompletes()
|
||||
public async Task ApproveReview_UnsubscribesFromOperationProgress_AfterTheCallCompletes()
|
||||
{
|
||||
var worker = new BlockingApproveWorkerClient();
|
||||
var vm = BuildVm(worker);
|
||||
@@ -228,12 +220,12 @@ public class DetailsIslandReviewActionsTests : IDisposable
|
||||
vm.Monitor.ApplyState(TaskStatus.WaitingForReview);
|
||||
|
||||
var approve = vm.ApproveReviewCommand.ExecuteAsync(null);
|
||||
Assert.Equal(1, MergeProgressSubscriberCount(worker));
|
||||
Assert.Equal(1, OperationProgressSubscriberCount(worker));
|
||||
|
||||
worker.Gate.SetResult(new MergeResultDto("merged", new List<string>(), null));
|
||||
await approve;
|
||||
|
||||
Assert.Equal(0, MergeProgressSubscriberCount(worker));
|
||||
Assert.Equal(0, OperationProgressSubscriberCount(worker));
|
||||
}
|
||||
|
||||
private static int OperationProgressSubscriberCount(StubWorkerClient worker)
|
||||
@@ -255,8 +247,7 @@ public class DetailsIslandReviewActionsTests : IDisposable
|
||||
var approve = vm.ApproveReviewCommand.ExecuteAsync(null);
|
||||
var merging = vm.ApproveOp.Label;
|
||||
|
||||
// The rebase phase needs the branch total, which only rides OperationProgress
|
||||
// (MergeProgressEvent forwards elapsed-seconds only) — ignored for other tasks.
|
||||
// The rebase phase needs the branch total — ignored for other tasks.
|
||||
worker.RaiseOperationProgress("some-other-task", "rebasing", 1, 3);
|
||||
Assert.Equal(merging, vm.ApproveOp.Label);
|
||||
|
||||
|
||||
@@ -30,4 +30,31 @@ public class GeneralSettingsTabViewModelTests
|
||||
|
||||
Assert.Equal(expectedValid, vm.Validate() is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, true)]
|
||||
[InlineData("", true)]
|
||||
[InlineData("09:00", true)]
|
||||
[InlineData("23:59", true)]
|
||||
[InlineData("9:0", false)]
|
||||
[InlineData("abc", false)]
|
||||
[InlineData("25:00", false)]
|
||||
public void Validate_PowerUseWindowStart_rejectsUnparseable(string? value, bool expectedValid)
|
||||
{
|
||||
var vm = new GeneralSettingsTabViewModel { PowerUseWindowStart = value };
|
||||
|
||||
Assert.Equal(expectedValid, vm.Validate() is null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, true)]
|
||||
[InlineData("09:00", true)]
|
||||
[InlineData("9:0", false)]
|
||||
[InlineData("abc", false)]
|
||||
public void Validate_PowerUseWindowEnd_rejectsUnparseable(string? value, bool expectedValid)
|
||||
{
|
||||
var vm = new GeneralSettingsTabViewModel { PowerUseWindowEnd = value };
|
||||
|
||||
Assert.Equal(expectedValid, vm.Validate() is null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +161,9 @@ public class ListSettingsModalViewModelTests
|
||||
string? reported = null;
|
||||
vm.ErrorReported += m => reported = m;
|
||||
|
||||
await vm.LoadAsync("l1", "List One", "/repo", "feat");
|
||||
// Empty working dir: this test is about ticket-project load isolation, not the
|
||||
// working-dir-is-a-repo gate added for the "not a git repo" Save check.
|
||||
await vm.LoadAsync("l1", "List One", "", "feat");
|
||||
|
||||
Assert.Equal("make test", vm.VerifyCommand);
|
||||
Assert.True(vm.PowerUseAllowed);
|
||||
@@ -169,4 +171,45 @@ public class ListSettingsModalViewModelTests
|
||||
Assert.NotNull(reported);
|
||||
Assert.Contains("ticket system down", reported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_WorkingDirNotAGitRepo_WarnsAndBlocksSave()
|
||||
{
|
||||
var plainDir = Directory.CreateTempSubdirectory().FullName;
|
||||
var worker = new CountingWorker { Cfg = new ListConfigDto("opus", null, null) };
|
||||
var vm = new ListSettingsModalViewModel(worker, NewFactory());
|
||||
|
||||
await vm.LoadAsync("l1", "List One", plainDir, "feat");
|
||||
|
||||
Assert.NotEmpty(vm.WorkingDirWarning);
|
||||
Assert.False(vm.SaveCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_WorkingDirIsAGitRepo_NoWarningAndSaveAllowed()
|
||||
{
|
||||
var repoDir = Directory.CreateTempSubdirectory().FullName;
|
||||
Directory.CreateDirectory(Path.Combine(repoDir, ".git"));
|
||||
var worker = new CountingWorker { Cfg = new ListConfigDto("opus", null, null) };
|
||||
var vm = new ListSettingsModalViewModel(worker, NewFactory());
|
||||
|
||||
await vm.LoadAsync("l1", "List One", repoDir, "feat");
|
||||
|
||||
Assert.Empty(vm.WorkingDirWarning);
|
||||
Assert.True(vm.SaveCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkingDir_ClearedAfterNotARepoWarning_ClearsWarningAndAllowsSave()
|
||||
{
|
||||
var plainDir = Directory.CreateTempSubdirectory().FullName;
|
||||
var worker = new CountingWorker { Cfg = new ListConfigDto("opus", null, null) };
|
||||
var vm = new ListSettingsModalViewModel(worker, NewFactory()) { WorkingDir = plainDir };
|
||||
Assert.NotEmpty(vm.WorkingDirWarning);
|
||||
|
||||
vm.WorkingDir = "";
|
||||
|
||||
Assert.Empty(vm.WorkingDirWarning);
|
||||
Assert.True(vm.SaveCommand.CanExecute(null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Localization;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Sidebar folders: grouping on load, the folder badge, and the two ways a list changes folder
|
||||
/// (dropped onto a row in another container, dropped onto a folder header).
|
||||
/// </summary>
|
||||
public class ListsIslandFolderTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
|
||||
public ListsIslandFolderTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_lists_folders_test_{Guid.NewGuid():N}.db");
|
||||
using var ctx = NewContext();
|
||||
ctx.Database.EnsureCreated();
|
||||
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales")))
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
Loc.Current = new Localizer(
|
||||
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var suffix in new[] { "", "-wal", "-shm" })
|
||||
try { File.Delete(_dbPath + suffix); } catch { }
|
||||
}
|
||||
|
||||
private ClaudeDoDbContext NewContext()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||
.UseSqlite($"Data Source={_dbPath}")
|
||||
.Options;
|
||||
return new ClaudeDoDbContext(opts);
|
||||
}
|
||||
|
||||
private sealed class FakeWorker : StubWorkerClient
|
||||
{
|
||||
public override bool IsConnected => true;
|
||||
}
|
||||
|
||||
private async Task SeedAsync(string? folderId = null, params (string Id, string Name, int Sort)[] lists) =>
|
||||
await SeedAsync(folderId, 0, lists);
|
||||
|
||||
private async Task SeedAsync(string? folderId, int folderSort, params (string Id, string Name, int Sort)[] lists)
|
||||
{
|
||||
await using var db = NewContext();
|
||||
if (folderId is not null)
|
||||
db.ListFolders.Add(new ListFolderEntity { Id = folderId, Name = $"Folder {folderId}", SortOrder = folderSort, CreatedAt = DateTime.UtcNow });
|
||||
foreach (var (id, name, sort) in lists)
|
||||
db.Lists.Add(new ListEntity { Id = id, Name = name, SortOrder = sort, CreatedAt = DateTime.UtcNow, FolderId = folderId });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedTaskAsync(string listId, int number)
|
||||
{
|
||||
await using var db = NewContext();
|
||||
db.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Number = number,
|
||||
ListId = listId,
|
||||
Title = "T",
|
||||
Status = Data.Models.TaskStatus.Idle,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private ListsIslandViewModel NewVm() => new(new TestDbFactory(NewContext), worker: new FakeWorker());
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_puts_lists_into_their_folder_and_leaves_the_rest_at_the_top_level()
|
||||
{
|
||||
await SeedAsync("f1", ("inside", "Inside", 0));
|
||||
await SeedAsync(null, ("outside", "Outside", 1));
|
||||
|
||||
var vm = NewVm();
|
||||
await vm.LoadAsync();
|
||||
|
||||
var folder = Assert.Single(vm.Folders);
|
||||
Assert.Equal("Inside", Assert.Single(folder.Lists).Name);
|
||||
Assert.Equal("Outside", Assert.Single(vm.RootLists).Name);
|
||||
// UserLists stays the flat superset — counts and row refreshes work over it.
|
||||
Assert.Equal(2, vm.UserLists.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Folder_count_is_the_sum_of_its_lists()
|
||||
{
|
||||
await SeedAsync("f1", ("a", "A", 0), ("b", "B", 1));
|
||||
await SeedTaskAsync("a", 1);
|
||||
await SeedTaskAsync("b", 2);
|
||||
await SeedTaskAsync("b", 3);
|
||||
|
||||
var vm = NewVm();
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Equal(3, Assert.Single(vm.Folders).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveToFolderAsync_moves_a_top_level_list_into_the_folder_and_persists_it()
|
||||
{
|
||||
await SeedAsync("f1", ("inside", "Inside", 0));
|
||||
await SeedAsync(null, ("mover", "Mover", 1));
|
||||
|
||||
var vm = NewVm();
|
||||
await vm.LoadAsync();
|
||||
var mover = vm.RootLists.Single(r => r.Id == "user:mover");
|
||||
|
||||
await vm.MoveToFolderAsync(mover, vm.Folders[0]);
|
||||
|
||||
Assert.Empty(vm.RootLists);
|
||||
Assert.Equal(2, vm.Folders[0].Lists.Count);
|
||||
Assert.Equal("f1", mover.FolderId);
|
||||
|
||||
await using var db = NewContext();
|
||||
Assert.Equal("f1", (await db.Lists.AsNoTracking().FirstAsync(l => l.Id == "mover")).FolderId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveToFolderAsync_with_no_folder_takes_a_list_back_out()
|
||||
{
|
||||
await SeedAsync("f1", ("inside", "Inside", 0));
|
||||
|
||||
var vm = NewVm();
|
||||
await vm.LoadAsync();
|
||||
var row = vm.Folders[0].Lists.Single();
|
||||
|
||||
await vm.MoveToFolderAsync(row, null);
|
||||
|
||||
Assert.Empty(vm.Folders[0].Lists);
|
||||
Assert.Same(row, Assert.Single(vm.RootLists));
|
||||
Assert.Null(row.FolderId);
|
||||
|
||||
await using var db = NewContext();
|
||||
Assert.Null((await db.Lists.AsNoTracking().FirstAsync(l => l.Id == "inside")).FolderId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderAsync_onto_a_row_in_another_container_also_changes_the_folder()
|
||||
{
|
||||
await SeedAsync("f1", ("inside", "Inside", 0));
|
||||
await SeedAsync(null, ("mover", "Mover", 1));
|
||||
|
||||
var vm = NewVm();
|
||||
await vm.LoadAsync();
|
||||
var mover = vm.RootLists.Single();
|
||||
var inside = vm.Folders[0].Lists.Single();
|
||||
|
||||
await vm.ReorderAsync(mover, inside, placeBelow: false);
|
||||
|
||||
Assert.Empty(vm.RootLists);
|
||||
Assert.Equal(["Mover", "Inside"], vm.Folders[0].Lists.Select(l => l.Name));
|
||||
Assert.Equal("f1", mover.FolderId);
|
||||
|
||||
await using var db = NewContext();
|
||||
var stored = await db.Lists.AsNoTracking().ToListAsync();
|
||||
Assert.Equal("f1", stored.Single(l => l.Id == "mover").FolderId);
|
||||
// sort_order is one sequence over the whole sidebar, folders included: f1 takes 0, its
|
||||
// two children 1 and 2.
|
||||
Assert.Equal(0, (await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f1")).SortOrder);
|
||||
Assert.Equal(1, stored.Single(l => l.Id == "mover").SortOrder);
|
||||
Assert.Equal(2, stored.Single(l => l.Id == "inside").SortOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DropAsync_reorders_folders_among_each_other_and_persists_it()
|
||||
{
|
||||
await SeedAsync("f1", folderSort: 0);
|
||||
await SeedAsync("f2", folderSort: 1);
|
||||
|
||||
var vm = NewVm();
|
||||
await vm.LoadAsync();
|
||||
var (first, second) = (vm.Folders[0], vm.Folders[1]);
|
||||
|
||||
await vm.DropAsync(second, first, placeBelow: false);
|
||||
|
||||
Assert.Equal([second, first], vm.Folders);
|
||||
await using var db = NewContext();
|
||||
var stored = await db.ListFolders.AsNoTracking().ToListAsync();
|
||||
Assert.Equal(0, stored.Single(f => f.Id == second.Id).SortOrder);
|
||||
Assert.Equal(1, stored.Single(f => f.Id == first.Id).SortOrder);
|
||||
|
||||
// And it survives a reload — the load path merges folders and lists by sort_order.
|
||||
var reloaded = NewVm();
|
||||
await reloaded.LoadAsync();
|
||||
Assert.Equal([second.Id, first.Id], reloaded.Folders.Select(f => f.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DropAsync_puts_a_list_above_a_folder_at_the_top_level()
|
||||
{
|
||||
await SeedAsync("f1", ("inside", "Inside", 1));
|
||||
await SeedAsync(null, ("mover", "Mover", 2));
|
||||
|
||||
var vm = NewVm();
|
||||
await vm.LoadAsync();
|
||||
var folder = vm.Folders[0];
|
||||
var mover = vm.RootLists.Single();
|
||||
|
||||
await vm.DropAsync(mover, folder, placeBelow: false);
|
||||
|
||||
Assert.Equal(new object[] { mover, folder }, vm.TopLevel);
|
||||
Assert.Null(mover.FolderId);
|
||||
|
||||
var reloaded = NewVm();
|
||||
await reloaded.LoadAsync();
|
||||
Assert.Equal(["user:mover", "f1"], reloaded.TopLevel.Select(i =>
|
||||
i is ListNavItemViewModel l ? l.Id : ((ListFolderNavItemViewModel)i).Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DropAsync_never_nests_a_folder_in_a_folder()
|
||||
{
|
||||
await SeedAsync("f1", 0, ("inside", "Inside", 1));
|
||||
await SeedAsync("f2", folderSort: 2);
|
||||
|
||||
var vm = NewVm();
|
||||
await vm.LoadAsync();
|
||||
var inside = vm.Folders[0].Lists.Single();
|
||||
var f2 = vm.Folders[1];
|
||||
|
||||
await vm.DropAsync(f2, inside, placeBelow: true);
|
||||
|
||||
Assert.Equal(2, vm.TopLevel.Count);
|
||||
Assert.Single(vm.Folders[0].Lists);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderAsync_within_a_folder_keeps_the_folder()
|
||||
{
|
||||
await SeedAsync("f1", ("a", "A", 0), ("b", "B", 1));
|
||||
|
||||
var vm = NewVm();
|
||||
await vm.LoadAsync();
|
||||
var a = vm.Folders[0].Lists[0];
|
||||
var b = vm.Folders[0].Lists[1];
|
||||
|
||||
await vm.ReorderAsync(b, a, placeBelow: false);
|
||||
|
||||
Assert.Equal(["B", "A"], vm.Folders[0].Lists.Select(l => l.Name));
|
||||
Assert.All(vm.Folders[0].Lists, l => Assert.Equal("f1", l.FolderId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToggleFolder_flips_and_persists_the_collapse_state()
|
||||
{
|
||||
await SeedAsync("f1", ("inside", "Inside", 0));
|
||||
|
||||
var vm = NewVm();
|
||||
await vm.LoadAsync();
|
||||
var folder = vm.Folders[0];
|
||||
Assert.False(folder.IsCollapsed);
|
||||
|
||||
await vm.ToggleFolderCommand.ExecuteAsync(folder);
|
||||
|
||||
Assert.True(folder.IsCollapsed);
|
||||
await using var db = NewContext();
|
||||
Assert.True((await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f1")).IsCollapsed);
|
||||
|
||||
// And the state survives a reload.
|
||||
var reloaded = NewVm();
|
||||
await reloaded.LoadAsync();
|
||||
Assert.True(reloaded.Folders[0].IsCollapsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveFromFolder_command_is_the_context_menu_route_out()
|
||||
{
|
||||
await SeedAsync("f1", ("inside", "Inside", 0));
|
||||
|
||||
var vm = NewVm();
|
||||
await vm.LoadAsync();
|
||||
var row = vm.Folders[0].Lists.Single();
|
||||
|
||||
await vm.RemoveFromFolderCommand.ExecuteAsync(row);
|
||||
|
||||
Assert.Null(row.FolderId);
|
||||
Assert.Single(vm.RootLists);
|
||||
}
|
||||
}
|
||||
@@ -21,17 +21,22 @@ public class MergeModalViewModelTests
|
||||
public TaskCompletionSource<MergeResultDto> MergeGate { get; } = new();
|
||||
public bool BlockMerge { get; set; }
|
||||
public string? CapturedCommitMessage { get; private set; }
|
||||
public bool CapturedAutostash { get; private set; }
|
||||
|
||||
/// Result for a call with autostash=false; the autostash retry always merges.
|
||||
public MergeResultDto CleanResult { get; set; } = new("merged", new List<string>(), null);
|
||||
|
||||
public override Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) =>
|
||||
Task.FromResult(Targets);
|
||||
|
||||
public override Task<MergeResultDto> MergeTaskAsync(
|
||||
string taskId, string targetBranch, bool removeWorktree, string commitMessage)
|
||||
string taskId, string targetBranch, bool removeWorktree, string commitMessage, bool autostash = false)
|
||||
{
|
||||
CapturedCommitMessage = commitMessage;
|
||||
CapturedAutostash = autostash;
|
||||
return BlockMerge
|
||||
? MergeGate.Task
|
||||
: Task.FromResult(new MergeResultDto("merged", new List<string>(), null));
|
||||
: Task.FromResult(autostash ? new MergeResultDto("merged", new List<string>(), null) : CleanResult);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +84,7 @@ public class MergeModalViewModelTests
|
||||
|
||||
// The verify gate starts — a distinct phase, so the user sees why this is taking minutes.
|
||||
// (The elapsed time rides in a format argument, which the test localizer drops.)
|
||||
worker.RaiseMergeProgress("task-1", "verifying", 90);
|
||||
worker.RaiseOperationProgress("task-1", "verifying", 90, 0);
|
||||
Assert.NotEqual(merging, vm.ProgressMessage);
|
||||
|
||||
worker.MergeGate.SetResult(new MergeResultDto("merged", new List<string>(), null));
|
||||
@@ -90,6 +95,26 @@ public class MergeModalViewModelTests
|
||||
Assert.True(vm.Merged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dirty_target_offers_the_stash_retry_and_merges_with_autostash()
|
||||
{
|
||||
var (vm, worker) = Build();
|
||||
await vm.InitializeAsync("task-1", "do the thing");
|
||||
worker.CleanResult = new MergeResultDto("dirty_target", new List<string>(), "target working tree has uncommitted changes");
|
||||
|
||||
await vm.SubmitCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.True(vm.ShowStashRetry);
|
||||
Assert.False(vm.Merged);
|
||||
Assert.False(worker.CapturedAutostash);
|
||||
|
||||
await vm.StashAndMergeCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.True(worker.CapturedAutostash);
|
||||
Assert.True(vm.Merged);
|
||||
Assert.False(vm.ShowStashRetry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Progress_for_another_task_is_ignored()
|
||||
{
|
||||
@@ -100,7 +125,7 @@ public class MergeModalViewModelTests
|
||||
var submit = vm.SubmitCommand.ExecuteAsync(null);
|
||||
var merging = vm.ProgressMessage;
|
||||
|
||||
worker.RaiseMergeProgress("some-other-task", "verifying", 42);
|
||||
worker.RaiseOperationProgress("some-other-task", "verifying", 42, 0);
|
||||
|
||||
Assert.Equal(merging, vm.ProgressMessage);
|
||||
|
||||
@@ -115,7 +140,7 @@ public class MergeModalViewModelTests
|
||||
await vm.InitializeAsync("task-1", "do the thing");
|
||||
|
||||
await vm.SubmitCommand.ExecuteAsync(null);
|
||||
worker.RaiseMergeProgress("task-1", "verifying", 10);
|
||||
worker.RaiseOperationProgress("task-1", "verifying", 10, 0);
|
||||
|
||||
Assert.Null(vm.ProgressMessage);
|
||||
}
|
||||
@@ -130,8 +155,7 @@ public class MergeModalViewModelTests
|
||||
var submit = vm.SubmitCommand.ExecuteAsync(null);
|
||||
var merging = vm.ProgressMessage;
|
||||
|
||||
// Needs the branch total, which only rides OperationProgress (MergeProgressEvent
|
||||
// forwards elapsed-seconds only) -- and must not be clobbered by that other forwarder.
|
||||
// Needs the branch total, which only OperationProgress carries.
|
||||
worker.RaiseOperationProgress("task-1", "rebasing", 1, 2);
|
||||
Assert.NotEqual(merging, vm.ProgressMessage);
|
||||
Assert.Contains("(1/2)", vm.ProgressMessage);
|
||||
|
||||
@@ -151,6 +151,30 @@ public class ConPtyPaneViewModelTests
|
||||
Assert.True(pane.RetryCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
// ── HasExitedAfterStartup banner (fix: a mid-run death used to leave a silent dead tile — no
|
||||
// StartError, no signal at all) ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RetryCommand_Enabled_AfterDeathAfterStartup()
|
||||
{
|
||||
using var pane = NewTaskPane(NeverCompletes);
|
||||
|
||||
DieAfterRunning(pane, exitCode: 1);
|
||||
|
||||
Assert.True(pane.Terminal.HasExitedAfterStartup);
|
||||
Assert.True(pane.RetryCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExitedBannerText_IncludesExitCode()
|
||||
{
|
||||
using var pane = NewTaskPane(NeverCompletes);
|
||||
|
||||
DieAfterRunning(pane, exitCode: 42);
|
||||
|
||||
Assert.Contains("42", pane.ExitedBannerText);
|
||||
}
|
||||
|
||||
// ── SessionDiedAfterStartup (feeds the worker's usage-limit auto-restart) ────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -195,6 +219,35 @@ public class ConPtyPaneViewModelTests
|
||||
Assert.True(raised);
|
||||
}
|
||||
|
||||
// ── Terminal.ErrorReported forwarding (fix: a failed paste/drop had no path to the footer
|
||||
// error strip) ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TerminalErrorReported_Forwards_ToPaneErrorReported()
|
||||
{
|
||||
using var pane = NewTaskPane(NeverCompletes);
|
||||
string? error = null;
|
||||
pane.ErrorReported += msg => error = msg;
|
||||
|
||||
pane.Terminal.ReportSendFailed();
|
||||
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TerminalErrorReported_StillWiredAfterRetry()
|
||||
{
|
||||
using var pane = NewTaskPane(() => Failing());
|
||||
pane.Start();
|
||||
pane.RetryCommand.Execute(null);
|
||||
string? error = null;
|
||||
pane.ErrorReported += msg => error = msg;
|
||||
|
||||
pane.Terminal.ReportSendFailed();
|
||||
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
private static void SetRunning(ConPtyPaneViewModel pane) => pane.Terminal.IsRunning = true;
|
||||
|
||||
// Drives the terminal past its startup grace window, then kills it: IsRunning stamps the launch
|
||||
|
||||
+56
@@ -36,6 +36,7 @@ public class InteractiveTerminalViewModelTests
|
||||
|
||||
Assert.True(terminal.HasExited);
|
||||
Assert.Null(terminal.StartError);
|
||||
Assert.True(terminal.HasExitedAfterStartup); // no StartError banner, so the host needs this signal instead
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -48,6 +49,30 @@ public class InteractiveTerminalViewModelTests
|
||||
|
||||
Assert.True(terminal.HasExited);
|
||||
Assert.Null(terminal.StartError);
|
||||
Assert.True(terminal.HasExitedAfterStartup);
|
||||
}
|
||||
|
||||
// ── HasExitedAfterStartup (drives the host's "session ended" banner — every death that
|
||||
// ISN'T already covered by StartError) ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FreshTerminal_HasExitedAfterStartupIsFalse()
|
||||
{
|
||||
using var terminal = new InteractiveTerminalViewModel(() => LaunchedAt);
|
||||
|
||||
Assert.False(terminal.HasExitedAfterStartup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessExitsNonzero_WithinGraceWindow_HasExitedAfterStartupIsFalse()
|
||||
{
|
||||
using var terminal = new InteractiveTerminalViewModel(() => LaunchedAt);
|
||||
terminal.IsRunning = true;
|
||||
|
||||
terminal.OnSessionProcessExited(null, 1); // startup failure — StartError covers it instead
|
||||
|
||||
Assert.NotNull(terminal.StartError);
|
||||
Assert.False(terminal.HasExitedAfterStartup);
|
||||
}
|
||||
|
||||
// ── ExitedAfterStartup (a session that ran, then broke -- used to be a silent dead tile;
|
||||
@@ -99,4 +124,35 @@ public class InteractiveTerminalViewModelTests
|
||||
terminal.IsRunning = true; // records LaunchedAt via the first clock call
|
||||
return terminal;
|
||||
}
|
||||
|
||||
// ── Paste/drop failure reporting (fix: PasteAsync used to swallow every exception and return
|
||||
// void, so a failed Ctrl+V or file drop gave the user no signal at all) ────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task PasteAsync_ReturnsFalse_WhenSessionNotRunning()
|
||||
{
|
||||
using var terminal = new InteractiveTerminalViewModel();
|
||||
|
||||
Assert.False(await terminal.PasteAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTextAsync_ReturnsFalse_WhenSessionNotRunning()
|
||||
{
|
||||
using var terminal = new InteractiveTerminalViewModel();
|
||||
|
||||
Assert.False(await terminal.SendTextAsync("dropped/path"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportSendFailed_RaisesErrorReported()
|
||||
{
|
||||
using var terminal = new InteractiveTerminalViewModel();
|
||||
string? error = null;
|
||||
terminal.ErrorReported += msg => error = msg;
|
||||
|
||||
terminal.ReportSendFailed();
|
||||
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,7 +556,7 @@ public class MissionControlViewModelTests : IDisposable
|
||||
|
||||
private sealed class ThrowingMergeHelperLaunchSpecWorker : StubWorkerClient
|
||||
{
|
||||
public override Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct = default)
|
||||
public override Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
|
||||
=> throw new InvalidOperationException("spec failed");
|
||||
}
|
||||
|
||||
@@ -1072,4 +1072,59 @@ public class MissionControlViewModelTests : IDisposable
|
||||
Assert.Equal(new[] { "t1", "t2" }, worker.Cancelled);
|
||||
Assert.Empty(vm.ConPtySessions);
|
||||
}
|
||||
|
||||
// The tile's own X must not silently kill a live session -- a reflex click on a running pane
|
||||
// has to go through the same confirm mechanism as Abandon Run.
|
||||
[Fact]
|
||||
public async Task Close_RunningSession_ConfirmDeclined_LeavesSessionOpen()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
await vm.OpenConPtySessionAsync("t1");
|
||||
var pane = vm.ConPtySessions[0];
|
||||
pane.Terminal.IsRunning = true;
|
||||
string? confirmMessage = null;
|
||||
vm.ConfirmAsync = msg => { confirmMessage = msg; return Task.FromResult(false); };
|
||||
|
||||
pane.CloseCommand.Execute(null);
|
||||
await Task.Delay(20);
|
||||
|
||||
Assert.NotNull(confirmMessage);
|
||||
Assert.Single(vm.ConPtySessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Close_RunningSession_ConfirmAccepted_ClosesPane()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
await vm.OpenConPtySessionAsync("t1");
|
||||
var pane = vm.ConPtySessions[0];
|
||||
pane.Terminal.IsRunning = true;
|
||||
vm.ConfirmAsync = _ => Task.FromResult(true);
|
||||
|
||||
pane.CloseCommand.Execute(null);
|
||||
await Task.Delay(20);
|
||||
|
||||
Assert.Empty(vm.ConPtySessions);
|
||||
}
|
||||
|
||||
// A dead session (start failure or exited-after-startup) is pure cleanup -- no confirm needed.
|
||||
[Fact]
|
||||
public async Task Close_DeadSession_ClosesWithoutConfirming()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
await vm.OpenConPtySessionAsync("t1");
|
||||
var pane = vm.ConPtySessions[0];
|
||||
pane.Terminal.ReportStartFailure("boom");
|
||||
var confirmCalled = false;
|
||||
vm.ConfirmAsync = _ => { confirmCalled = true; return Task.FromResult(true); };
|
||||
|
||||
pane.CloseCommand.Execute(null);
|
||||
await Task.Delay(20);
|
||||
|
||||
Assert.False(confirmCalled);
|
||||
Assert.Empty(vm.ConPtySessions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,10 +153,17 @@ public class SettingsModalViewModelTests : IDisposable
|
||||
Assert.Equal("18:00", vm.General.PowerUseWindowStart);
|
||||
Assert.Equal(88, vm.General.PowerUseHandoffPct);
|
||||
|
||||
// A typo in the window is normalized to null on save, not stored as a fake window.
|
||||
// A typo in the window blocks Save instead of silently becoming "round the clock".
|
||||
vm.General.PowerUseWindowEnd = "not a time";
|
||||
await vm.SaveCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Null(worker.Saved);
|
||||
Assert.NotEqual("", vm.ValidationError);
|
||||
|
||||
// Clearing it back out is still a valid "round the clock" window and saves.
|
||||
vm.General.PowerUseWindowEnd = "";
|
||||
await vm.SaveCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.True(worker.Saved!.PowerUseEnabled);
|
||||
Assert.Equal("18:00", worker.Saved.PowerUseWindowStart);
|
||||
Assert.Null(worker.Saved.PowerUseWindowEnd);
|
||||
|
||||
@@ -37,7 +37,7 @@ public class TasksIslandApproveReviewTests : IDisposable
|
||||
private sealed class ThrowingWorkerClient : StubWorkerClient
|
||||
{
|
||||
public string ExceptionMessage { get; init; } = "blocked: target working tree has uncommitted changes";
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) =>
|
||||
throw new Exception(ExceptionMessage);
|
||||
}
|
||||
|
||||
|
||||
@@ -266,4 +266,93 @@ public class TasksIslandChainTests : IDisposable
|
||||
Assert.Contains(numbers["a"].ToString(), wiring.ChainAfterLabel);
|
||||
Assert.Contains(numbers["b"].ToString(), wiring.ChainAfterLabel);
|
||||
}
|
||||
|
||||
// Usability-Audit 2026-09-10: a Queued task blocked on an unmet task_dependencies edge used to
|
||||
// render the same "queued" chip as one that's actually next up to run.
|
||||
[Fact]
|
||||
public async Task QueuedTaskWithUnmetDependency_ShowsWaitingChipAndTooltip()
|
||||
{
|
||||
var numbers = await SeedAsync(
|
||||
("predecessor", TaskStatus.Idle, null, null, 0),
|
||||
("dependent", TaskStatus.Queued, "predecessor", null, 1));
|
||||
|
||||
var vm = BuildViewModel();
|
||||
await LoadAndWaitAsync(vm, UserList("list1", "Default"));
|
||||
|
||||
var dependent = vm.Items.First(r => r.Id == "dependent");
|
||||
|
||||
Assert.True(dependent.IsWaiting);
|
||||
Assert.False(dependent.IsQueued);
|
||||
Assert.Equal("waiting", dependent.StatusChipClass);
|
||||
Assert.NotNull(dependent.StatusChipTooltip);
|
||||
Assert.Contains(numbers["predecessor"].ToString(), dependent.StatusChipTooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueuedTaskWithSatisfiedDependency_ShowsPlainQueuedChip()
|
||||
{
|
||||
await SeedAsync(
|
||||
("predecessor", TaskStatus.Done, null, null, 0),
|
||||
("dependent", TaskStatus.Queued, "predecessor", null, 1));
|
||||
|
||||
var vm = BuildViewModel();
|
||||
await LoadAndWaitAsync(vm, UserList("list1", "Default"));
|
||||
|
||||
var dependent = vm.Items.First(r => r.Id == "dependent");
|
||||
|
||||
Assert.False(dependent.IsWaiting);
|
||||
Assert.True(dependent.IsQueued);
|
||||
Assert.Equal("queued", dependent.StatusChipClass);
|
||||
Assert.Null(dependent.DependencyWaitLabel);
|
||||
}
|
||||
|
||||
// Usability-Audit 2026-09-10: BuildAfterLabel only resolved predecessors already loaded into
|
||||
// Items, so a listenfremder Vorgänger (different list) silently dropped both the "after #N"
|
||||
// chip and the blocked chip. RefreshExternalDependencyCacheAsync backfills Number/Title/Status
|
||||
// for exactly those ids via one extra query on the already-open DbContext.
|
||||
[Fact]
|
||||
public async Task CrossListPredecessor_BlocksAndRendersAfterLabel()
|
||||
{
|
||||
var headNumber = TestTaskNumbers.Next();
|
||||
await using (var db = NewContext())
|
||||
{
|
||||
db.Lists.Add(new ListEntity { Id = "list1", Name = "Default", CreatedAt = DateTime.UtcNow });
|
||||
db.Lists.Add(new ListEntity { Id = "list2", Name = "Other", CreatedAt = DateTime.UtcNow });
|
||||
db.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Number = headNumber,
|
||||
Id = "head",
|
||||
ListId = "list1",
|
||||
Title = "head",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Status = TaskStatus.Idle,
|
||||
SortOrder = 0,
|
||||
});
|
||||
db.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Number = TestTaskNumbers.Next(),
|
||||
Id = "dependent",
|
||||
ListId = "list2",
|
||||
Title = "dependent",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Status = TaskStatus.Queued,
|
||||
SortOrder = 0,
|
||||
});
|
||||
db.TaskDependencies.Add(new TaskDependencyEntity { TaskId = "dependent", DependsOnTaskId = "head" });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var vm = BuildViewModel();
|
||||
await LoadAndWaitAsync(vm, UserList("list2", "Other"));
|
||||
|
||||
var dependent = vm.Items.First(r => r.Id == "dependent");
|
||||
|
||||
Assert.True(dependent.IsWaiting);
|
||||
Assert.False(dependent.IsQueued);
|
||||
Assert.Equal("waiting", dependent.StatusChipClass);
|
||||
Assert.NotNull(dependent.ChainAfterLabel);
|
||||
Assert.Contains($"#{headNumber}", dependent.ChainAfterLabel);
|
||||
Assert.NotNull(dependent.DependencyWaitLabel);
|
||||
Assert.Contains($"#{headNumber}", dependent.DependencyWaitLabel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public sealed class WorkerConnectionModalViewModelTests
|
||||
{
|
||||
private sealed class FakeWorkerLocator(string? path) : WorkerLocator
|
||||
{
|
||||
public override string? Find() => path;
|
||||
}
|
||||
|
||||
private sealed class FakeInstallerLocator(string? path) : InstallerLocator
|
||||
{
|
||||
public override string? Find() => path;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartWorker_ExeNotFound_ShowsErrorAndKeepsDialogOpen()
|
||||
{
|
||||
var vm = new WorkerConnectionModalViewModel(new FakeWorkerLocator(null), new FakeInstallerLocator(null));
|
||||
var closed = false;
|
||||
vm.CloseAction = () => closed = true;
|
||||
|
||||
vm.StartWorkerCommand.Execute(null);
|
||||
|
||||
Assert.False(closed);
|
||||
Assert.False(string.IsNullOrEmpty(vm.ErrorText));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartWorker_ProcessStartThrows_ShowsErrorAndKeepsDialogOpen()
|
||||
{
|
||||
var vm = new WorkerConnectionModalViewModel(new FakeWorkerLocator(@"C:\fake\ClaudeDo.Worker.exe"), new FakeInstallerLocator(null))
|
||||
{
|
||||
ProcessStarter = _ => throw new InvalidOperationException("blocked by AV")
|
||||
};
|
||||
var closed = false;
|
||||
vm.CloseAction = () => closed = true;
|
||||
|
||||
vm.StartWorkerCommand.Execute(null);
|
||||
|
||||
Assert.False(closed);
|
||||
Assert.Contains("blocked by AV", vm.ErrorText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartWorker_Success_ClearsErrorAndClosesDialog()
|
||||
{
|
||||
var vm = new WorkerConnectionModalViewModel(new FakeWorkerLocator(@"C:\fake\ClaudeDo.Worker.exe"), new FakeInstallerLocator(null))
|
||||
{
|
||||
ProcessStarter = _ => { }
|
||||
};
|
||||
var closed = false;
|
||||
vm.CloseAction = () => closed = true;
|
||||
|
||||
vm.StartWorkerCommand.Execute(null);
|
||||
|
||||
Assert.True(closed);
|
||||
Assert.Null(vm.ErrorText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RerunInstaller_NotFound_ShowsErrorAndDoesNotThrow()
|
||||
{
|
||||
var vm = new WorkerConnectionModalViewModel(new FakeWorkerLocator(null), new FakeInstallerLocator(null));
|
||||
|
||||
vm.RerunInstallerCommand.Execute(null);
|
||||
|
||||
Assert.False(string.IsNullOrEmpty(vm.ErrorText));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RerunInstaller_ProcessStartThrows_ShowsError()
|
||||
{
|
||||
var vm = new WorkerConnectionModalViewModel(new FakeWorkerLocator(null), new FakeInstallerLocator(@"C:\fake\ClaudeDo.Installer.exe"))
|
||||
{
|
||||
ProcessStarter = _ => throw new InvalidOperationException("blocked by AV")
|
||||
};
|
||||
|
||||
vm.RerunInstallerCommand.Execute(null);
|
||||
|
||||
Assert.Contains("blocked by AV", vm.ErrorText);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.External;
|
||||
|
||||
public sealed class ListHandlerMcpToolsTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly ListRepository _lists;
|
||||
private readonly CapturingHubContext _hubContext = new();
|
||||
|
||||
public ListHandlerMcpToolsTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_tasks = new TaskRepository(_ctx);
|
||||
_lists = new ListRepository(_ctx);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_ctx.Dispose();
|
||||
_db.Dispose();
|
||||
}
|
||||
|
||||
private ListHandlerMcpTools BuildSut(ConnectedClientTracker tracker) =>
|
||||
new(_lists, _tasks, new HubBroadcaster(_hubContext), tracker);
|
||||
|
||||
private async Task<string> SeedListAsync()
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
return listId;
|
||||
}
|
||||
|
||||
private async Task<TaskEntity> SeedTaskAsync(string listId, TaskStatus status = TaskStatus.Idle, string title = "t")
|
||||
{
|
||||
var task = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(), ListId = listId, Title = title,
|
||||
Status = status, CreatedAt = DateTime.UtcNow, CommitType = "chore",
|
||||
};
|
||||
await _tasks.AddAsync(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartListHandler_NoConnectedClient_DoesNotBroadcastAndReportsNotRequested()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
await SeedTaskAsync(listId);
|
||||
var tracker = new ConnectedClientTracker();
|
||||
|
||||
var result = await BuildSut(tracker).StartListHandler(listId, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.False(result.Requested);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Reason));
|
||||
Assert.Equal(1, result.TaskCount);
|
||||
Assert.Empty(_hubContext.Proxy.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartListHandler_ConnectedClient_BroadcastsAndReportsRequested()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
var tracker = new ConnectedClientTracker();
|
||||
tracker.Increment();
|
||||
|
||||
var result = await BuildSut(tracker).StartListHandler(listId, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.True(result.Requested);
|
||||
Assert.Null(result.Reason);
|
||||
Assert.Equal(1, result.TaskCount);
|
||||
Assert.Equal(new[] { task.Number }, result.TaskNumbers);
|
||||
|
||||
var call = Assert.Single(_hubContext.Proxy.Calls);
|
||||
Assert.Equal("ListHandlerStartRequested", call.Method);
|
||||
Assert.Equal(listId, call.Args[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartListHandler_LastClientDisconnects_StopsReportingRequested()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
await SeedTaskAsync(listId);
|
||||
var tracker = new ConnectedClientTracker();
|
||||
tracker.Increment();
|
||||
tracker.Decrement();
|
||||
|
||||
var result = await BuildSut(tracker).StartListHandler(listId, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.False(result.Requested);
|
||||
Assert.Empty(_hubContext.Proxy.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartListHandler_NoTasksToHandle_ThrowsRegardlessOfConnection()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var tracker = new ConnectedClientTracker();
|
||||
tracker.Increment();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
BuildSut(tracker).StartListHandler(listId, cancellationToken: CancellationToken.None));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user