Compare commits

...
15 Commits
Author SHA1 Message Date
Mika Kuns 63d8b5c28d fix(installer): unbreak the update path and cache the download
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 40s
Every update failed at "Could not replace the existing files": the app
relaunches the installer via ShellExecute without a working directory, so
it inherited the app's CWD - which the Start Menu shortcut sets to
<InstallDir>\app. A process's current directory is locked by Windows, so
the installer blocked its own `app` -> `app.bak` rename. Retries and
reboots could not help.

- installer moves its CWD to %TEMP% at startup, and both relaunch sites
  in the UI pass an explicit WorkingDirectory
- cache the release zip in %TEMP%\ClaudeDo-download-cache and reuse it on
  a retry while its SHA-256 still matches, so a failed attempt no longer
  costs another full download; drop it after a successful install, delete
  a mismatching one, prune zips of other versions
- roll back a half-done stash: a leftover app.bak was deleted as a stale
  stash on the next attempt, and that copy was the only one left
- name the blocked path in the error message
2026-08-04 17:09:10 +02:00
mika kuns ab56644ddc fix(ui): clear task selection when switching lists
Switching lists rebuilt Tasks.Items from scratch but left SelectedTask pointing
at a row from the previous list, so the detail pane kept showing that task even
though it is not in the visible list. Drop the selection when the list actually
changes; a reload of the same list (worker refresh, reconnect) keeps it so a
live update never yanks the detail pane away.
2026-08-04 17:09:10 +02:00
ClaudeDo CI 71050e2634 docs(changelog): update for v2.5.0 2026-07-29 15:02:02 +00:00
Mika Kuns ef7645c6b1 Merge claudedo/a6a2df8848a8460aa1fe57c1b69aff47
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 38s
2026-07-29 15:11:35 +02:00
Mika Kuns 92ce7a4a77 feat(ui): move a task to another list via drag & drop
Dragging a task row onto a user list in the Lists island now reassigns it
to that list. The task drag holds the pointer capture, so the release is
resolved geometrically (new case between the Mission Control and reorder
cases) instead of going through the Lists island's own DragDrop path,
which never sees a DragEventArgs during a task drag.

TaskRepository.MoveToListAsync reassigns the task plus every descendant
(a child must never sit in a different list than its parent) and appends
the task at the end of the target list. Guards: running tasks and tasks
holding an Active/Kept worktree are rejected to the footer error strip;
a move that changes repo asks for confirmation naming both repos.

The source repo is read from the task's own list rather than the island's
current list, which is a smart/virtual list with no working dir of its own
whenever one of those is shown.
2026-07-29 15:10:59 +02:00
mika kuns 4dc4fe27e3 Merge claudedo/f4d2d2f8eef340ca82540a189f9e299a 2026-07-29 13:29:18 +02:00
mika kuns 0ee30bee03 Merge claudedo/3832008a0b5147bf8308407ed4bd1ded 2026-07-29 13:26:15 +02:00
mika kuns b9e0721875 Merge claudedo/113934cda24a4ae99e15d77fca29507e 2026-07-29 13:25:08 +02:00
mika kuns 92eb654f9b Merge claudedo/50a6027eba294dde8b18e4082dfc1e9b 2026-07-29 13:24:50 +02:00
mika kuns 724814f770 Merge claudedo/9c97a8ca2e134729bf96ca282cfbf346 2026-07-29 13:24:26 +02:00
mika kuns b5464fc533 fix(worker-client): stop swallowing mutating hub call failures
TryInvokeAsync catches every exception and returns null, which is fine
for read-only calls but hid real HubException reasons behind a generic
"offline" message for the 7 mutating call sites (RestoreDefaultAgents,
UpsertPrimeSchedule, AddDailyNote, CleanupFinishedWorktrees,
ResetAllWorktrees, ForceRemoveWorktree, BuildPlanningIntegrationBranch)
— the same bug class fixed for ApproveReview in e1807fd. Each of the 22
TryInvokeAsync call sites was audited; the 15 read-only ones are left
unchanged (empty/offline is the right display). For the 7 switched to
a direct hub invoke, every caller was checked and, where it had no
catch, one was added so the exception surfaces (StatusMessage,
ShowErrorAsync/CombinedWarning) instead of crashing.
2026-07-29 13:19:38 +02:00
mika kuns 44cdad386c feat(mission-control): sidebar shows queued+running, no auto-monitor seeding
- TaskStarted no longer creates a monitor pane; Panes stays empty unless a
  ConPTY session is explicitly opened
- SeedActive no longer called on construction or ConnectionRestored
- EnsureMonitor / SeedActive kept compiling and functional (internal), just unused
- RefreshQueueAsync now loads Queued + Running tasks, Running sorted first
- QueuedTaskViewModel gains IsRunning + OpenInAppCommand (IRelayCommand)
- Sidebar row is a Button; running rows get RunningTint overlay + "Running" label
- HasQueued is true whenever any queued or running task exists
- New localization keys: missionControl.running (en + de)
- Tests updated: 12 monitor-machinery tests use EnsureMonitor directly;
  3 new acceptance-criteria tests (no-auto-pane, running-first sort, row click)
2026-07-29 12:12:34 +02:00
mika kuns 58f8b11dbb fix(worker-tests): pin LogRingBuffer clock in Does_not_throw_when_detached
The test built the buffer with the real DateTime.UtcNow clock but emitted
an event with the file's fixed EvtTime timestamp (2026-06-23). As real
time drifted more than the 1h window past that fixed timestamp, the
just-appended record was evicted inside the same Append call, before
Snapshot() ran. BroadcastLogSink.Emit itself buffers unconditionally
regardless of attach state, so this was a test bug, not a sink bug. Pin
the buffer's clock to EvtTime, matching every sibling test's NewSink setup.
2026-07-29 12:10:58 +02:00
mika kuns e653677487 feat(worker): expose maxParallelExecutions in get_app_settings
The list-handler prompt (MergeHelperDefault) tells the handler to read
maxParallelExecutions via get_app_settings, but the DTO never carried
the field. Handler had to fall back to reading app_settings directly
from the DB on the 2026-07-29 E2E run.
2026-07-29 12:06:52 +02:00
mika kuns 994e94c2af fix(claude-do): List-Handler-Session mit --permission-mode auto starten
## Problem
Die "Let Claude handle it"-Session (List-Handler) startet mit `--permission-mode default` und fragt
dadurch bei jedem Tool-Aufruf nach Bestätigung. Sie soll autonom durchlaufen können — der User
überwacht die ConPTY-Kachel, statt jede Aktion einzeln freizugeben.

## Ist-Zustand
`src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs`, `BuildForMergeHelperAsync`
(Zeile ~183-249). Die

ClaudeDo-Task: 50a6027eba294dde8b18e4082dfc1e9b
2026-07-29 12:03:30 +02:00
39 changed files with 1568 additions and 127 deletions
+21
View File
@@ -1,5 +1,26 @@
# Changelog # Changelog
## v2.5.0 — 2026-07-29
### Features
- move a task to another list via drag & drop (92ce7a4)
- sidebar shows queued+running, no auto-monitor seeding (44cdad3)
- expose maxParallelExecutions in get_app_settings (e653677)
- accent color presets in Settings → General (149e2ad)
- allow Done via update_task_status with worktree guard (d569313)
- add Let Claude handle it broom button to tasks header (7d6cb2b)
### Fixes
- stop swallowing mutating hub call failures (b5464fc)
- pin LogRingBuffer clock in Does_not_throw_when_detached (58f8b11)
- propagate HubException from ApproveReviewAsync so blocked merges surface errors (e1807fd)
### Documentation
- update for v2.4.0 (3fbbd7a)
## v2.4.0 — 2026-07-27 ## v2.4.0 — 2026-07-27
### Features ### Features
@@ -84,6 +84,60 @@ public sealed class TaskRepository
public Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default) public Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default)
=> GetByListIdAsync(listId, ct); => GetByListIdAsync(listId, ct);
/// <summary>
/// Returns the ids of every descendant of <paramref name="taskId"/> (children, grandchildren, ...),
/// walking the ParentTaskId chain breadth-first. Does not include the task itself.
/// </summary>
public async Task<List<string>> GetDescendantIdsAsync(string taskId, CancellationToken ct = default)
{
var result = new List<string>();
// Guards against a corrupt parent chain (a self-parent or a cycle would loop forever).
var seen = new HashSet<string>(StringComparer.Ordinal) { taskId };
var frontier = new List<string> { taskId };
while (frontier.Count > 0)
{
var children = await _context.Tasks.AsNoTracking()
.Where(t => t.ParentTaskId != null && frontier.Contains(t.ParentTaskId))
.Select(t => t.Id)
.ToListAsync(ct);
var fresh = children.Where(seen.Add).ToList();
if (fresh.Count == 0) break;
result.AddRange(fresh);
frontier = fresh;
}
return result;
}
/// <summary>
/// Moves a task (and every descendant, so a child never ends up in a different list than its
/// parent) to <paramref name="targetListId"/>, appending it at the end of the target list's
/// order. ListId is init-only, so the move goes through ExecuteUpdate rather than a tracked
/// entity mutation.
/// </summary>
public async Task MoveToListAsync(string taskId, string targetListId, CancellationToken ct = default)
{
var exists = await _context.Tasks.AsNoTracking().AnyAsync(t => t.Id == taskId, ct);
if (!exists)
throw new InvalidOperationException($"Task {taskId} not found.");
var descendantIds = await GetDescendantIdsAsync(taskId, ct);
var movedIds = new List<string> { taskId };
movedIds.AddRange(descendantIds);
var maxSort = await _context.Tasks
.Where(t => t.ListId == targetListId)
.Select(t => (int?)t.SortOrder)
.MaxAsync(ct);
await _context.Tasks
.Where(t => movedIds.Contains(t.Id))
.ExecuteUpdateAsync(s => s.SetProperty(t => t.ListId, targetListId), ct);
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.SortOrder, (maxSort ?? -1) + 1), ct);
}
public async Task<List<TaskEntity>> GetByCreatorAsync(string createdBy, CancellationToken ct = default) public async Task<List<TaskEntity>> GetByCreatorAsync(string createdBy, CancellationToken ct = default)
{ {
return await _context.Tasks return await _context.Tasks
+8 -1
View File
@@ -27,6 +27,13 @@ public partial class App : Application
{ {
base.OnStartup(e); base.OnStartup(e);
// The app relaunches us via ShellExecute without an explicit working directory, so we
// inherit its CWD — which the Start Menu shortcut sets to <InstallDir>\app. A process's
// current directory is locked by the OS, so we would block DownloadAndExtractStep's
// Directory.Move of app\ against ourselves (fails on every attempt, reboot included).
// Step out of the install tree before anything else touches it.
try { Environment.CurrentDirectory = Path.GetTempPath(); } catch { /* best effort */ }
// --- Initialize localizer as early as possible so all windows can use {loc:Tr} --- // --- Initialize localizer as early as possible so all windows can use {loc:Tr} ---
var localesDir = Path.Combine(AppContext.BaseDirectory, "locales"); var localesDir = Path.Combine(AppContext.BaseDirectory, "locales");
var localeStore = LocaleStore.Load(localesDir); var localeStore = LocaleStore.Load(localesDir);
@@ -124,7 +131,7 @@ public partial class App : Application
// Steps — execution order matters for the FreshInstall pipeline (IEnumerable<IInstallStep>). // Steps — execution order matters for the FreshInstall pipeline (IEnumerable<IInstallStep>).
// Double-registered as both IInstallStep and concrete type so the Update pipeline // Double-registered as both IInstallStep and concrete type so the Update pipeline
// can pull them out individually via GetRequiredService<T>(). // can pull them out individually via GetRequiredService<T>().
sc.AddSingleton<DownloadAndExtractStep>(); sc.AddSingleton(sp => new DownloadAndExtractStep(sp.GetRequiredService<IReleaseClient>()));
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>()); sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>());
sc.AddSingleton<IInstallStep, WriteConfigStep>(); sc.AddSingleton<IInstallStep, WriteConfigStep>();
sc.AddSingleton<IInstallStep, InitDatabaseStep>(); sc.AddSingleton<IInstallStep, InitDatabaseStep>();
+6
View File
@@ -82,6 +82,12 @@ Non-fatal if `claude` CLI is missing or too old (prints the manual command). Ser
No new service or scheduled task is created. Rationale: the worker must run in the user's interactive session so Claude CLI auth works. No new service or scheduled task is created. Rationale: the worker must run in the user's interactive session so Claude CLI auth works.
**`DownloadAndExtractStep`** — fetches `checksums.txt` first and only touches the install dir after the zip verifies. The zip is cached in `%TEMP%\ClaudeDo-download-cache` (ctor takes an override for tests) and reused on a retry when its SHA-256 still matches, so a failed attempt doesn't cost another full download; it is dropped after a successful install, a bad download is deleted immediately, and zips of other versions are pruned. `app\`/`worker\` are stashed to `*.bak` before extraction and restored if extraction fails.
### Gotcha: the installer must never run from inside the install dir
`App.OnStartup` sets `Environment.CurrentDirectory` to `%TEMP%`, and the UI passes an explicit `WorkingDirectory` when it relaunches us. A process's current directory is locked by Windows: inheriting the app's CWD (`<InstallDir>\app`, from the Start Menu shortcut's "start in") made the installer block its own `app``app.bak` rename, so every update failed with "Could not replace the existing files" — unaffected by retries or a reboot. Keep both guards.
## `InstallContext` Defaults ## `InstallContext` Defaults
| Property | Default | | Property | Default |
@@ -9,10 +9,14 @@ namespace ClaudeDo.Installer.Steps;
public sealed class DownloadAndExtractStep : IInstallStep public sealed class DownloadAndExtractStep : IInstallStep
{ {
private readonly IReleaseClient _releases; private readonly IReleaseClient _releases;
private readonly string _cacheDir;
public DownloadAndExtractStep(IReleaseClient releases) public DownloadAndExtractStep(IReleaseClient releases, string? cacheDirectory = null)
{ {
_releases = releases; _releases = releases;
// Downloads survive a failed attempt so a retry doesn't pull ~100 MB again.
// %TEMP% because Storage Sense ages the cache out on its own.
_cacheDir = cacheDirectory ?? Path.Combine(Path.GetTempPath(), "ClaudeDo-download-cache");
} }
public string Name => "Download and Extract"; public string Name => "Download and Extract";
@@ -38,38 +42,50 @@ public sealed class DownloadAndExtractStep : IInstallStep
if (checksumAsset is null) if (checksumAsset is null)
return StepResult.Fail("checksums.txt not found in release metadata."); return StepResult.Fail("checksums.txt not found in release metadata.");
var scratchDir = Path.Combine(Path.GetTempPath(), "ClaudeDo-install-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_cacheDir);
Directory.CreateDirectory(scratchDir); var zipPath = Path.Combine(_cacheDir, zipAsset.Name);
var checksumPath = Path.Combine(_cacheDir, "checksums.txt");
PruneCacheExcept(zipAsset.Name);
try
{ {
var zipPath = Path.Combine(scratchDir, zipAsset.Name);
var checksumPath = Path.Combine(scratchDir, "checksums.txt");
var totalMb = zipAsset.Size / (1024 * 1024);
progress.Report($"Downloading {zipAsset.Name} ({totalMb} MB)...");
long lastReportedMb = -1;
await _releases.DownloadAsync(zipAsset.BrowserDownloadUrl, zipPath,
new Progress<long>(b =>
{
var mb = b / (1024 * 1024);
if (mb == lastReportedMb) return;
lastReportedMb = mb;
// Leading "\r" tells the UI to overwrite the previous line instead of appending.
progress.Report($"\r {mb} / {totalMb} MB downloaded");
}),
ct);
progress.Report("Downloading checksums..."); progress.Report("Downloading checksums...");
await _releases.DownloadAsync(checksumAsset.BrowserDownloadUrl, checksumPath, await _releases.DownloadAsync(checksumAsset.BrowserDownloadUrl, checksumPath,
new Progress<long>(_ => { }), ct); new Progress<long>(_ => { }), ct);
progress.Report("Verifying checksum...");
var map = ChecksumVerifier.ParseChecksumsFile(await File.ReadAllTextAsync(checksumPath, ct)); var map = ChecksumVerifier.ParseChecksumsFile(await File.ReadAllTextAsync(checksumPath, ct));
if (!map.TryGetValue(zipAsset.Name, out var expectedHash)) if (!map.TryGetValue(zipAsset.Name, out var expectedHash))
return StepResult.Fail($"No checksum entry for {zipAsset.Name} in checksums.txt."); return StepResult.Fail($"No checksum entry for {zipAsset.Name} in checksums.txt.");
if (!ChecksumVerifier.Verify(zipPath, expectedHash))
return StepResult.Fail("Checksum mismatch — the downloaded zip may be corrupt or tampered with."); // An earlier attempt may have failed after the download (locked files, bad
// extraction). Reuse that zip when it still verifies rather than re-downloading.
if (File.Exists(zipPath) && ChecksumVerifier.Verify(zipPath, expectedHash))
{
progress.Report($"Reusing the already downloaded {zipAsset.Name}.");
}
else
{
var totalMb = zipAsset.Size / (1024 * 1024);
progress.Report($"Downloading {zipAsset.Name} ({totalMb} MB)...");
long lastReportedMb = -1;
await _releases.DownloadAsync(zipAsset.BrowserDownloadUrl, zipPath,
new Progress<long>(b =>
{
var mb = b / (1024 * 1024);
if (mb == lastReportedMb) return;
lastReportedMb = mb;
// Leading "\r" tells the UI to overwrite the previous line instead of appending.
progress.Report($"\r {mb} / {totalMb} MB downloaded");
}),
ct);
progress.Report("Verifying checksum...");
if (!ChecksumVerifier.Verify(zipPath, expectedHash))
{
// Never keep a bad download around — it would be re-verified forever.
TryDelete(zipPath);
return StepResult.Fail("Checksum mismatch — the downloaded zip may be corrupt or tampered with.");
}
}
// Only after verification do we touch the install directory. // Only after verification do we touch the install directory.
progress.Report("Stashing previous app/worker binaries..."); progress.Report("Stashing previous app/worker binaries...");
@@ -78,20 +94,31 @@ public sealed class DownloadAndExtractStep : IInstallStep
var appBak = appDest + ".bak"; var appBak = appDest + ".bak";
var workerBak = workerDest + ".bak"; var workerBak = workerDest + ".bak";
var stashedApp = false;
var failedPath = ctx.InstallDirectory;
try try
{ {
failedPath = appBak;
if (Directory.Exists(appBak)) DeleteWithRetry(appBak); if (Directory.Exists(appBak)) DeleteWithRetry(appBak);
failedPath = workerBak;
if (Directory.Exists(workerBak)) DeleteWithRetry(workerBak); if (Directory.Exists(workerBak)) DeleteWithRetry(workerBak);
if (Directory.Exists(appDest)) MoveWithRetry(appDest, appBak); failedPath = appDest;
if (Directory.Exists(appDest)) { MoveWithRetry(appDest, appBak); stashedApp = true; }
failedPath = workerDest;
if (Directory.Exists(workerDest)) MoveWithRetry(workerDest, workerBak); if (Directory.Exists(workerDest)) MoveWithRetry(workerDest, workerBak);
} }
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{ {
// Undo a half-done stash: a leftover app.bak would be deleted as a stale
// stash on the next attempt — that copy is the only one we still have.
if (stashedApp && !Directory.Exists(appDest) && Directory.Exists(appBak))
try { MoveWithRetry(appBak, appDest); } catch { /* best effort */ }
// A just-stopped app/worker (or an Explorer/terminal window sitting in // A just-stopped app/worker (or an Explorer/terminal window sitting in
// the install dir) still held a handle. Surface an actionable message // the install dir) still held a handle. Surface an actionable message
// instead of the raw "process cannot access the file" error. // instead of the raw "process cannot access the file" error.
return StepResult.Fail( return StepResult.Fail(
"Could not replace the existing app/worker files — they are still in use. " + $"Could not replace '{failedPath}' — it is still in use. " +
"Make sure ClaudeDo is fully closed (app and worker) and no Explorer or " + "Make sure ClaudeDo is fully closed (app and worker) and no Explorer or " +
$"terminal window is open inside the install folder, then run the update again. Details: {ex.Message}"); $"terminal window is open inside the install folder, then run the update again. Details: {ex.Message}");
} }
@@ -117,13 +144,32 @@ public sealed class DownloadAndExtractStep : IInstallStep
if (Directory.Exists(appBak)) DeleteWithRetry(appBak); if (Directory.Exists(appBak)) DeleteWithRetry(appBak);
if (Directory.Exists(workerBak)) DeleteWithRetry(workerBak); if (Directory.Exists(workerBak)) DeleteWithRetry(workerBak);
// Installed — the cached zip has served its purpose.
TryDelete(zipPath);
TryDelete(checksumPath);
ctx.InstalledVersion = release.TagName.TrimStart('v', 'V'); ctx.InstalledVersion = release.TagName.TrimStart('v', 'V');
return StepResult.Ok(); return StepResult.Ok();
} }
finally }
// Zips from earlier attempts on other versions would pile up otherwise.
private void PruneCacheExcept(string keepFileName)
{
try
{ {
try { Directory.Delete(scratchDir, recursive: true); } catch { /* best effort */ } foreach (var file in Directory.EnumerateFiles(_cacheDir, "*.zip"))
{
if (!string.Equals(Path.GetFileName(file), keepFileName, StringComparison.OrdinalIgnoreCase))
TryDelete(file);
}
} }
catch { /* best effort */ }
}
private static void TryDelete(string file)
{
try { File.Delete(file); } catch { /* best effort */ }
} }
private static void MoveWithRetry(string source, string dest) private static void MoveWithRetry(string source, string dest)
+5 -4
View File
@@ -277,6 +277,7 @@
"settings": "Einstellungen", "settings": "Einstellungen",
"queue": "Warteschlange", "queue": "Warteschlange",
"blocked": "Blockiert", "blocked": "Blockiert",
"running": "Läuft",
"focusMode": "Fokus", "focusMode": "Fokus",
"overviewMode": "Übersicht", "overviewMode": "Übersicht",
"closeSession": "Sitzung schließen", "closeSession": "Sitzung schließen",
@@ -541,9 +542,9 @@
"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" },
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" }, "planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" }, "taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "pickUpInTerminalFailed": "Im Terminal fortsetzen fehlgeschlagen: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}" }, "tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "pickUpInTerminalFailed": "Im Terminal fortsetzen fehlgeschlagen: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "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." },
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." }, "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)." }, "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": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" }, "merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
"conflictResolution": { "vsCodeError": "VS Code konnte nicht gestartet werden: {0}. Die Pfade sind oben aufgeführt — kopiere sie manuell.", "subtaskPrefix": "Konflikte in Teilaufgabe: {0}", "targetPrefix": "Zusammenführen in: {0}" }, "conflictResolution": { "vsCodeError": "VS Code konnte nicht gestartet werden: {0}. Die Pfade sind oben aufgeführt — kopiere sie manuell.", "subtaskPrefix": "Konflikte in Teilaufgabe: {0}", "targetPrefix": "Zusammenführen in: {0}" },
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" }, "settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
@@ -551,8 +552,8 @@
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" }, "weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" },
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "openFailed": "Öffnen fehlgeschlagen: {0}" }, "filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "openFailed": "Öffnen fehlgeschlagen: {0}" },
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" }, "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." }, "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.", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." }, "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "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}", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." },
"listSettings": { "untitled": "Unbenannt" }, "listSettings": { "untitled": "Unbenannt" },
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" } "lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" }
} }
+5 -4
View File
@@ -277,6 +277,7 @@
"settings": "Settings", "settings": "Settings",
"queue": "Queue", "queue": "Queue",
"blocked": "Blocked", "blocked": "Blocked",
"running": "Running",
"focusMode": "Focus", "focusMode": "Focus",
"overviewMode": "Overview", "overviewMode": "Overview",
"closeSession": "Close session", "closeSession": "Close session",
@@ -541,9 +542,9 @@
"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" },
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" }, "planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" }, "taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "pickUpInTerminalFailed": "Pick up in terminal failed: {0}", "approveFailed": "Approve & merge failed: {0}" }, "tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "pickUpInTerminalFailed": "Pick up in terminal failed: {0}", "approveFailed": "Approve & merge failed: {0}", "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." },
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." }, "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)." }, "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": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" }, "merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
"conflictResolution": { "vsCodeError": "Could not launch VS Code: {0}. Paths are listed above — copy them manually.", "subtaskPrefix": "Conflicts in subtask: {0}", "targetPrefix": "Merging into: {0}" }, "conflictResolution": { "vsCodeError": "Could not launch VS Code: {0}. Paths are listed above — copy them manually.", "subtaskPrefix": "Conflicts in subtask: {0}", "targetPrefix": "Merging into: {0}" },
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" }, "settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
@@ -551,8 +552,8 @@
"weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" }, "weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" },
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "openFailed": "Open failed: {0}" }, "filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "openFailed": "Open failed: {0}" },
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" }, "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)." }, "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.", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." }, "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "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}", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." },
"listSettings": { "untitled": "Untitled" }, "listSettings": { "untitled": "Untitled" },
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" } "lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" }
} }
@@ -693,6 +693,10 @@
<Setter Property="CornerRadius" Value="8" /> <Setter Property="CornerRadius" Value="8" />
<Setter Property="Cursor" Value="Hand" /> <Setter Property="Cursor" Value="Hand" />
<Setter Property="Background" Value="Transparent" /> <Setter Property="Background" Value="Transparent" />
<!-- Reserved so the task-drop highlight below only swaps the brush — a thickness that changes
on hover would nudge the row's content while dragging over it. -->
<Setter Property="BorderThickness" Value="1.5" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style> </Style>
<Style Selector="Border.list-item:pointerover"> <Style Selector="Border.list-item:pointerover">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" /> <Setter Property="Background" Value="{StaticResource Surface2Brush}" />
@@ -700,6 +704,11 @@
<Style Selector="Border.list-item.active"> <Style Selector="Border.list-item.active">
<Setter Property="Background" Value="{StaticResource AccentSoftBrush}" /> <Setter Property="Background" Value="{StaticResource AccentSoftBrush}" />
</Style> </Style>
<!-- Highlighted while a dragged task hovers over this row as a move target. -->
<Style Selector="Border.list-item.drop-target">
<Setter Property="Background" Value="{StaticResource AccentSoftBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
</Style>
<!-- Active item text / icon colors --> <!-- Active item text / icon colors -->
<Style Selector="Border.list-item.active TextBlock.list-label"> <Style Selector="Border.list-item.active TextBlock.list-label">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" /> <Setter Property="Foreground" Value="{StaticResource TextBrush}" />
+14 -14
View File
@@ -329,8 +329,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("RefreshAgents"); await _hub.InvokeAsync("RefreshAgents");
} }
public Task<SeedResultDto?> RestoreDefaultAgentsAsync() public async Task<SeedResultDto?> RestoreDefaultAgentsAsync()
=> TryInvokeAsync<SeedResultDto>("RestoreDefaultAgents"); => await _hub.InvokeAsync<SeedResultDto>("RestoreDefaultAgents");
private async Task SeedActiveTasksAsync() private async Task SeedActiveTasksAsync()
{ {
@@ -372,8 +372,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync() public async Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync()
=> await TryInvokeAsync<List<PrimeScheduleDto>>("ListPrimeSchedules") ?? new List<PrimeScheduleDto>(); => await TryInvokeAsync<List<PrimeScheduleDto>>("ListPrimeSchedules") ?? new List<PrimeScheduleDto>();
public Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto) public async Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto)
=> TryInvokeAsync<PrimeScheduleDto>("UpsertPrimeSchedule", dto); => await _hub.InvokeAsync<PrimeScheduleDto>("UpsertPrimeSchedule", dto);
public async Task DeletePrimeScheduleAsync(Guid id) public async Task DeletePrimeScheduleAsync(Guid id)
{ {
@@ -400,8 +400,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day) public async Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day)
=> await TryInvokeAsync<List<DailyNoteDto>>("GetDailyNotes", IsoDay(day)) ?? new List<DailyNoteDto>(); => await TryInvokeAsync<List<DailyNoteDto>>("GetDailyNotes", IsoDay(day)) ?? new List<DailyNoteDto>();
public Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text) public async Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text)
=> TryInvokeAsync<DailyNoteDto>("AddDailyNote", IsoDay(day), text); => await _hub.InvokeAsync<DailyNoteDto>("AddDailyNote", IsoDay(day), text);
public async Task UpdateDailyNoteAsync(string id, string text) public async Task UpdateDailyNoteAsync(string id, string text)
=> await _hub.InvokeAsync("UpdateDailyNote", id, text); => await _hub.InvokeAsync("UpdateDailyNote", id, text);
@@ -474,11 +474,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("CancelReview", taskId); await _hub.InvokeAsync("CancelReview", taskId);
} }
public Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null) public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
=> TryInvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId); => await _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
public Task<WorktreeResetDto?> ResetAllWorktreesAsync() public async Task<WorktreeResetDto?> ResetAllWorktreesAsync()
=> TryInvokeAsync<WorktreeResetDto>("ResetAllWorktrees"); => await _hub.InvokeAsync<WorktreeResetDto>("ResetAllWorktrees");
public async Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId) public async Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId)
=> await TryInvokeAsync<List<WorktreeOverviewDto>>("GetWorktreesOverview", listId) => await TryInvokeAsync<List<WorktreeOverviewDto>>("GetWorktreesOverview", listId)
@@ -501,8 +501,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
} }
} }
public Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId) public async Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId)
=> TryInvokeAsync<ForceRemoveResultDto>("ForceRemoveWorktree", taskId); => await _hub.InvokeAsync<ForceRemoveResultDto>("ForceRemoveWorktree", taskId);
public async Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default) public async Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct); => await _hub.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct);
@@ -543,8 +543,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId) public async Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId)
=> await TryInvokeAsync<List<SubtaskDiffDto>>("GetPlanningAggregate", planningTaskId) ?? []; => await TryInvokeAsync<List<SubtaskDiffDto>>("GetPlanningAggregate", planningTaskId) ?? [];
public Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) public async Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch)
=> TryInvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch); => await _hub.InvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch);
public async Task ContinuePlanningMergeAsync(string planningTaskId) public async Task ContinuePlanningMergeAsync(string planningTaskId)
{ {
@@ -311,6 +311,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
Prep = new PrepPanelViewModel(worker); Prep = new PrepPanelViewModel(worker);
Notes = new NotesEditorViewModel(_notesApi); Notes = new NotesEditorViewModel(_notesApi);
Notes.ErrorReported += msg => { if (ShowErrorAsync is not null) _ = ShowErrorAsync(msg); };
Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged(); Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged();
Subtasks.CollectionChanged += (_, _) => Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count); Subtasks.CollectionChanged += (_, _) => Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count);
Attachments.CollectionChanged += (_, _) => OnPropertyChanged(nameof(FilesBadge)); Attachments.CollectionChanged += (_, _) => OnPropertyChanged(nameof(FilesBadge));
@@ -16,6 +16,9 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
[ObservableProperty] private bool _isManual; [ObservableProperty] private bool _isManual;
[ObservableProperty] private bool _dropHintAbove; [ObservableProperty] private bool _dropHintAbove;
[ObservableProperty] private bool _dropHintBelow; [ObservableProperty] private bool _dropHintBelow;
// Set while a dragged task hovers over this row as a move target — distinct from
// DropHintAbove/Below, which mark an insertion point for list reordering.
[ObservableProperty] private bool _isTaskDropTarget;
public string? IconKey { get; init; } public string? IconKey { get; init; }
public string? DotColorKey { get; init; } public string? DotColorKey { get; init; }
} }
@@ -24,6 +24,9 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
public NotesEditorViewModel(INotesApi api) => _api = api; public NotesEditorViewModel(INotesApi api) => _api = api;
// Raised when a worker call fails so the host VM can surface it (e.g. via ShowErrorAsync).
public event Action<string>? ErrorReported;
public ObservableCollection<NoteBulletViewModel> Bullets { get; } = new(); public ObservableCollection<NoteBulletViewModel> Bullets { get; } = new();
[ObservableProperty] private DateOnly _currentDay = DateOnly.FromDateTime(DateTime.Today); [ObservableProperty] private DateOnly _currentDay = DateOnly.FromDateTime(DateTime.Today);
@@ -55,9 +58,16 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
{ {
var text = NewBulletText.Trim(); var text = NewBulletText.Trim();
if (text.Length == 0) return; if (text.Length == 0) return;
var dto = await _api.AddAsync(CurrentDay, text); try
if (dto is not null) Bullets.Add(MakeBullet(dto.Id, dto.Text)); {
NewBulletText = ""; var dto = await _api.AddAsync(CurrentDay, text);
if (dto is not null) Bullets.Add(MakeBullet(dto.Id, dto.Text));
NewBulletText = "";
}
catch (Exception ex)
{
ErrorReported?.Invoke(ex.Message);
}
} }
[RelayCommand] private Task PrevDay() => LoadDayAsync(CurrentDay.AddDays(-1)); [RelayCommand] private Task PrevDay() => LoadDayAsync(CurrentDay.AddDays(-1));
@@ -231,6 +231,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
_loadCts = new CancellationTokenSource(); _loadCts = new CancellationTokenSource();
var ct = _loadCts.Token; var ct = _loadCts.Token;
// Items is rebuilt from scratch below, so a selection carried over from the previous list
// would leave the detail pane bound to a task the visible list no longer contains. Only a
// *different* list drops it — a reload of the same list (worker refresh, reconnect) keeps
// the selection so a live update never yanks the detail pane away.
var listChanged = !string.Equals(_currentList?.Id, list?.Id, StringComparison.Ordinal);
if (_currentList is not null) if (_currentList is not null)
_currentList.PropertyChanged -= OnCurrentListPropertyChanged; _currentList.PropertyChanged -= OnCurrentListPropertyChanged;
_currentList = list; _currentList = list;
@@ -246,6 +252,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
HasCompleted = false; HasCompleted = false;
ShowOpenLabel = false; ShowOpenLabel = false;
ShowNotesRow = false; ShowNotesRow = false;
if (listChanged) SelectedTask = null;
if (list is null) { IsLetClaudeVisible = false; LoadTask = Task.CompletedTask; return; } if (list is null) { IsLetClaudeVisible = false; LoadTask = Task.CompletedTask; return; }
HeaderTitle = list.Name; HeaderTitle = list.Name;
@@ -471,6 +478,11 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
} }
public bool CanReorder => _currentList?.Kind == ListKind.User; public bool CanReorder => _currentList?.Kind == ListKind.User;
public string? CurrentListId => _currentList?.Id;
/// <summary>Set by the shell (mirrors <see cref="ListsIslandViewModel.Dialogs"/>) so a
/// cross-repo move can confirm via the shared modal seam.</summary>
public IDialogService? Dialogs { get; set; }
public void ClearDropHints() public void ClearDropHints()
{ {
@@ -573,6 +585,76 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
return null; return null;
} }
/// <summary>
/// Drag-drop move: reassigns a task (and every descendant) to <paramref name="targetList"/>.
/// No-op for anything that isn't a user list the task doesn't already sit in.
/// Rejects running tasks and tasks (or descendants) holding an Active/Kept worktree — the
/// worktree would keep pointing at the source repo. A move across repos without a worktree
/// is allowed but asks for confirmation first.
/// </summary>
public async Task MoveTaskToListAsync(TaskRowViewModel row, ListNavItemViewModel targetList)
{
if (targetList.Kind != ListKind.User) return;
var targetListId = targetList.Id.StartsWith("user:", StringComparison.Ordinal)
? targetList.Id["user:".Length..]
: targetList.Id;
await using var db = await _dbFactory.CreateDbContextAsync();
// The row's own list, not _currentList — the task island also shows smart/virtual lists,
// whose rows come from many lists and which carry no working dir of their own. Dropping a
// task on the list it already sits in is a silent no-op.
var sourceListId = await db.Tasks.AsNoTracking()
.Where(t => t.Id == row.Id).Select(t => t.ListId).FirstOrDefaultAsync();
if (sourceListId is null || sourceListId == targetListId) return;
if (row.IsRunning)
{
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.moveRunningRejected"));
return;
}
var repo = new TaskRepository(db);
var descendantIds = await repo.GetDescendantIdsAsync(row.Id);
var ownIds = new List<string> { row.Id };
ownIds.AddRange(descendantIds);
var hasBlockingWorktree = await db.Worktrees.AsNoTracking()
.Where(w => ownIds.Contains(w.TaskId)
&& (w.State == ClaudeDo.Data.Models.WorktreeState.Active
|| w.State == ClaudeDo.Data.Models.WorktreeState.Kept))
.AnyAsync();
if (hasBlockingWorktree)
{
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.moveWorktreeRejected"));
return;
}
var sourceDir = await db.Lists.AsNoTracking()
.Where(l => l.Id == sourceListId).Select(l => l.WorkingDir).FirstOrDefaultAsync();
var targetDir = targetList.WorkingDir;
var repoChanges = !string.Equals(sourceDir, targetDir, StringComparison.OrdinalIgnoreCase);
if (repoChanges)
{
if (Dialogs is null)
{
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.moveConfirmUnavailable"));
return;
}
var ok = await Dialogs.ConfirmAsync(Loc.T("vm.tasksIsland.moveRepoConfirm",
string.IsNullOrWhiteSpace(sourceDir) ? "—" : sourceDir,
string.IsNullOrWhiteSpace(targetDir) ? "—" : targetDir));
if (!ok) return;
}
await repo.MoveToListAsync(row.Id, targetListId);
Items.Remove(row);
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand] [RelayCommand]
private async Task ToggleDoneAsync(TaskRowViewModel row) private async Task ToggleDoneAsync(TaskRowViewModel row)
{ {
@@ -56,6 +56,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
{ {
_dialogs = value; _dialogs = value;
if (Lists is not null) Lists.Dialogs = value; if (Lists is not null) Lists.Dialogs = value;
if (Tasks is not null) Tasks.Dialogs = value;
} }
} }
@@ -495,7 +496,13 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
try try
{ {
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true }); // WorkingDirectory must NOT stay empty: the child would inherit ours (<InstallDir>\app)
// and its locked current directory blocks the installer's own app\ rename.
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path)
{
UseShellExecute = true,
WorkingDirectory = System.IO.Path.GetTempPath(),
});
Environment.Exit(0); Environment.Exit(0);
} }
catch catch
@@ -81,7 +81,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
ConPtySessions.CollectionChanged += OnConPtySessionsChanged; ConPtySessions.CollectionChanged += OnConPtySessionsChanged;
Panes.CollectionChanged += OnPanesChanged; Panes.CollectionChanged += OnPanesChanged;
_onTaskStarted = (slot, taskId, startedAt) => { EnsureMonitor(taskId); _ = RefreshQueueAsync(); }; _onTaskStarted = (slot, taskId, startedAt) => { _ = RefreshQueueAsync(); };
_worker.TaskStartedEvent += _onTaskStarted; _worker.TaskStartedEvent += _onTaskStarted;
_onTaskFinished = (slot, taskId, status, finishedAt) => _ = RefreshQueueAsync(); _onTaskFinished = (slot, taskId, status, finishedAt) => _ = RefreshQueueAsync();
@@ -90,10 +90,9 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
_onTaskUpdated = taskId => _ = RefreshQueueAsync(); _onTaskUpdated = taskId => _ = RefreshQueueAsync();
_worker.TaskUpdatedEvent += _onTaskUpdated; _worker.TaskUpdatedEvent += _onTaskUpdated;
_onConnectionRestored = () => { SeedActive(); _ = RefreshQueueAsync(); }; _onConnectionRestored = () => { _ = RefreshQueueAsync(); };
_worker.ConnectionRestoredEvent += _onConnectionRestored; _worker.ConnectionRestoredEvent += _onConnectionRestored;
SeedActive();
_ = RefreshQueueAsync(); _ = RefreshQueueAsync();
} }
@@ -103,19 +102,26 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
{ {
await using var ctx = await _dbFactory.CreateDbContextAsync(); await using var ctx = await _dbFactory.CreateDbContextAsync();
var rows = await ctx.Tasks.AsNoTracking() var rows = await ctx.Tasks.AsNoTracking()
.Where(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Queued) .Where(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Queued
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) || t.Status == ClaudeDo.Data.Models.TaskStatus.Running)
.Select(t => new { t.Id, t.Title, t.BlockedByTaskId }) .OrderBy(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Running ? 0 : 1)
.ThenBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.Select(t => new { t.Id, t.Title, t.BlockedByTaskId, t.Status })
.ToListAsync(); .ToListAsync();
Queued.Clear(); Queued.Clear();
foreach (var r in rows) foreach (var r in rows)
{
var id = r.Id;
Queued.Add(new QueuedTaskViewModel Queued.Add(new QueuedTaskViewModel
{ {
Id = r.Id, Id = id,
Title = r.Title ?? string.Empty, Title = r.Title ?? string.Empty,
IsBlocked = r.BlockedByTaskId != null, IsBlocked = r.BlockedByTaskId != null,
IsRunning = r.Status == ClaudeDo.Data.Models.TaskStatus.Running,
OpenInAppCommand = new RelayCommand(() => _openInApp?.Invoke(id)),
}); });
}
OnPropertyChanged(nameof(HasQueued)); OnPropertyChanged(nameof(HasQueued));
} }
catch { /* best-effort queue refresh */ } catch { /* best-effort queue refresh */ }
@@ -141,13 +147,15 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
await RefreshQueueAsync(); await RefreshQueueAsync();
} }
private void SeedActive() // Kept intentionally — unused since auto-seeding was disabled (2026-07-29).
internal void SeedActive()
{ {
foreach (var a in _worker.GetActiveTasks()) foreach (var a in _worker.GetActiveTasks())
EnsureMonitor(a.TaskId); EnsureMonitor(a.TaskId);
} }
private void EnsureMonitor(string taskId) // Kept intentionally — unused since auto-seeding was disabled (2026-07-29).
internal void EnsureMonitor(string taskId)
{ {
if (string.IsNullOrEmpty(taskId)) return; if (string.IsNullOrEmpty(taskId)) return;
if (Monitors.Any(m => m.SubscribedTaskId == taskId)) return; if (Monitors.Any(m => m.SubscribedTaskId == taskId)) return;
@@ -428,10 +436,12 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
} }
} }
/// <summary>Read-only display row for a queued task in the Mission Control side strip.</summary> /// <summary>Read-only display row for a queued or running task in the Mission Control side strip.</summary>
public sealed class QueuedTaskViewModel public sealed class QueuedTaskViewModel
{ {
public required string Id { get; init; } public required string Id { get; init; }
public required string Title { get; init; } public required string Title { get; init; }
public bool IsBlocked { get; init; } public bool IsBlocked { get; init; }
public bool IsRunning { get; init; }
public IRelayCommand? OpenInAppCommand { get; init; }
} }
@@ -197,6 +197,11 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
DisplayedDiff = ""; DisplayedDiff = "";
} }
} }
catch (Exception ex)
{
DisplayedDiff = "";
CombinedWarning = Loc.T("vm.planningDiff.buildFailed", ex.Message);
}
finally finally
{ {
IsLoadingCombined = false; IsLoadingCombined = false;
@@ -46,6 +46,7 @@ public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
var r = await _worker.CleanupFinishedWorktreesAsync(); var r = await _worker.CleanupFinishedWorktreesAsync();
StatusMessage = r is null ? Loc.T("vm.worktreesTab.workerOffline") : Loc.T("vm.worktreesTab.removed", r.Removed); StatusMessage = r is null ? Loc.T("vm.worktreesTab.workerOffline") : Loc.T("vm.worktreesTab.removed", r.Removed);
} }
catch (Exception ex) { StatusMessage = Loc.T("vm.worktreesTab.cleanupFailed", ex.Message); }
finally { IsBusy = false; } finally { IsBusy = false; }
} }
@@ -63,6 +64,7 @@ public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
else if (r.Blocked) StatusMessage = Loc.T("vm.worktreesTab.blocked", r.RunningTasks); else if (r.Blocked) StatusMessage = Loc.T("vm.worktreesTab.blocked", r.RunningTasks);
else StatusMessage = Loc.T("vm.worktreesTab.removedFrom", r.Removed, r.TasksAffected); else StatusMessage = Loc.T("vm.worktreesTab.removedFrom", r.Removed, r.TasksAffected);
} }
catch (Exception ex) { StatusMessage = Loc.T("vm.worktreesTab.resetFailed", ex.Message); }
finally { IsBusy = false; } finally { IsBusy = false; }
} }
} }
@@ -1,5 +1,6 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.IO;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
@@ -37,7 +38,13 @@ public sealed partial class WorkerConnectionModalViewModel : ViewModelBase
if (path is null) return; if (path is null) return;
try try
{ {
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); // See IslandsShellViewModel.UpdateNow: an inherited CWD inside the install dir
// makes the installer block its own app\ rename.
Process.Start(new ProcessStartInfo(path)
{
UseShellExecute = true,
WorkingDirectory = Path.GetTempPath(),
});
Environment.Exit(0); Environment.Exit(0);
} }
catch { /* nothing useful to show */ } catch { /* nothing useful to show */ }
@@ -174,6 +174,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
StatusMessage = result is null ? Loc.T("vm.worktreesOverview.cleanupFailed") : Loc.T("vm.worktreesOverview.removed", result.Removed); StatusMessage = result is null ? Loc.T("vm.worktreesOverview.cleanupFailed") : Loc.T("vm.worktreesOverview.removed", result.Removed);
await LoadAsync(); await LoadAsync();
} }
catch (Exception ex) { StatusMessage = Loc.T("vm.worktreesOverview.cleanupFailedDetailed", ex.Message); }
finally { IsBusy = false; } finally { IsBusy = false; }
} }
@@ -241,7 +242,16 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
if (row.IsRunning) { StatusMessage = Loc.T("vm.worktreesOverview.cannotForceRunning"); return; } if (row.IsRunning) { StatusMessage = Loc.T("vm.worktreesOverview.cannotForceRunning"); return; }
if (ConfirmAction is not null && !await ConfirmAction($"Force remove worktree for '{row.TaskTitle}'? This deletes the directory and branch.")) return; if (ConfirmAction is not null && !await ConfirmAction($"Force remove worktree for '{row.TaskTitle}'? This deletes the directory and branch.")) return;
var result = await _worker.ForceRemoveWorktreeAsync(row.TaskId); ForceRemoveResultDto? result;
try
{
result = await _worker.ForceRemoveWorktreeAsync(row.TaskId);
}
catch (Exception ex)
{
StatusMessage = Loc.T("vm.worktreesOverview.forceRemoveFailedDetailed", ex.Message);
return;
}
if (result is null || !result.Removed) if (result is null || !result.Removed)
{ {
StatusMessage = result?.Reason ?? Loc.T("vm.worktreesOverview.forceRemoveFailed"); StatusMessage = result?.Reason ?? Loc.T("vm.worktreesOverview.forceRemoveFailed");
@@ -116,6 +116,7 @@
IsVisible="{Binding DropHintAbove}"/> IsVisible="{Binding DropHintAbove}"/>
<Border Grid.Row="1" Classes="list-item" Classes.active="{Binding IsActive}" <Border Grid.Row="1" Classes="list-item" Classes.active="{Binding IsActive}"
Classes.drop-target="{Binding IsTaskDropTarget}"
Tapped="OnItemTapped" Tapped="OnItemTapped"
DragDrop.AllowDrop="True" DragDrop.AllowDrop="True"
DragDrop.DragOver="OnListDragOver" DragDrop.DragOver="OnListDragOver"
@@ -31,6 +31,9 @@ public partial class TasksIslandView : UserControl
private bool _dragArmed; private bool _dragArmed;
private bool _dragging; private bool _dragging;
// The list row (in the Lists island) currently highlighted as a drop target while dragging.
private ListNavItemViewModel? _hintedList;
public TasksIslandView() public TasksIslandView()
{ {
InitializeComponent(); InitializeComponent();
@@ -170,6 +173,7 @@ public partial class TasksIslandView : UserControl
{ {
_drag.MoveTo(this.PointToScreen(e.GetPosition(this))); _drag.MoveTo(this.PointToScreen(e.GetPosition(this)));
UpdateReorderHint(e, topLevel); UpdateReorderHint(e, topLevel);
UpdateListDropHint(e, topLevel);
} }
} }
@@ -205,7 +209,17 @@ public partial class TasksIslandView : UserControl
return; return;
} }
// 2) Released over another row in the same user list → reorder. // 2) Released over another user list in the Lists island → move the task there.
if (DataContext is TasksIslandViewModel vmMove
&& ListItemUnder(e, topLevel) is { } targetList
&& targetList.Kind == ListKind.User
&& targetList.Id != vmMove.CurrentListId)
{
await vmMove.MoveTaskToListAsync(row, targetList);
return;
}
// 3) Released over another row in the same user list → reorder.
if (DataContext is TasksIslandViewModel vm && vm.CanReorder) if (DataContext is TasksIslandViewModel vm && vm.CanReorder)
{ {
var targetButton = RowButtonAt(e, topLevel); var targetButton = RowButtonAt(e, topLevel);
@@ -218,7 +232,7 @@ public partial class TasksIslandView : UserControl
} }
} }
// 3) Anywhere else → cancel; EndDrag already restored the source row. // 4) Anywhere else → cancel; EndDrag already restored the source row.
} }
private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
@@ -229,6 +243,7 @@ public partial class TasksIslandView : UserControl
if (!_dragArmed && !_dragging) return; if (!_dragArmed && !_dragging) return;
if (_pressRow is not null) _pressRow.IsDragging = false; if (_pressRow is not null) _pressRow.IsDragging = false;
if (DataContext is TasksIslandViewModel vm) vm.ClearDropHints(); if (DataContext is TasksIslandViewModel vm) vm.ClearDropHints();
ClearListDropHint();
_drag.End(); _drag.End();
ResetPressState(); ResetPressState();
} }
@@ -237,6 +252,7 @@ public partial class TasksIslandView : UserControl
{ {
if (_pressRow is not null) _pressRow.IsDragging = false; if (_pressRow is not null) _pressRow.IsDragging = false;
if (DataContext is TasksIslandViewModel vm) vm.ClearDropHints(); if (DataContext is TasksIslandViewModel vm) vm.ClearDropHints();
ClearListDropHint();
_drag.End(); _drag.End();
if (_dragging) e.Pointer.Capture(null); if (_dragging) e.Pointer.Capture(null);
ResetPressState(); ResetPressState();
@@ -295,6 +311,44 @@ public partial class TasksIslandView : UserControl
return button?.DataContext is TaskRowViewModel ? button : null; return button?.DataContext is TaskRowViewModel ? button : null;
} }
// The list-row Border under the cursor (Lists island), found the same way as RowButtonAt but
// walking up to a Border with the "list-item" style class (mirrors ListsIslandView's own
// FindListItemBorder) — the task drag has the pointer captured here, so a DragEventArgs never
// reaches the Lists island's own DragDrop.DragOver/Drop handlers.
private static ListNavItemViewModel? ListItemUnder(PointerEventArgs e, TopLevel topLevel)
{
var pt = e.GetPosition((Visual)topLevel);
Visual? v = topLevel.InputHitTest(pt) as Visual;
while (v is not null)
{
if (v is Border b && b.Classes.Contains("list-item"))
return b.DataContext as ListNavItemViewModel;
v = v.GetVisualParent();
}
return null;
}
// Live drop-hint while dragging over a candidate target list row.
private void UpdateListDropHint(PointerEventArgs e, TopLevel topLevel)
{
if (DataContext is not TasksIslandViewModel vm) { ClearListDropHint(); return; }
var target = ListItemUnder(e, topLevel);
if (target is null || target.Kind != ListKind.User || target.Id == vm.CurrentListId)
target = null;
if (ReferenceEquals(_hintedList, target)) return;
if (_hintedList is not null) _hintedList.IsTaskDropTarget = false;
_hintedList = target;
if (_hintedList is not null) _hintedList.IsTaskDropTarget = true;
}
private void ClearListDropHint()
{
if (_hintedList is not null) _hintedList.IsTaskDropTarget = false;
_hintedList = null;
}
// The Mission Control view model whose window contains the release point, if any. // The Mission Control view model whose window contains the release point, if any.
private static MissionControlViewModel? MissionControlUnder(PixelPoint screen) private static MissionControlViewModel? MissionControlUnder(PixelPoint screen)
{ {
@@ -73,21 +73,35 @@
<ItemsControl ItemsSource="{Binding Queued}"> <ItemsControl ItemsSource="{Binding Queued}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:QueuedTaskViewModel"> <DataTemplate x:DataType="vm:QueuedTaskViewModel">
<Border Margin="0,0,0,4" Padding="8,6" <Button Command="{Binding OpenInAppCommand}"
Background="{DynamicResource SurfaceBrush}" Padding="0" Margin="0,0,0,4"
BorderBrush="{DynamicResource LineBrush}" Background="Transparent" BorderThickness="0"
BorderThickness="1" CornerRadius="6"> HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
<StackPanel Spacing="2"> Cursor="Hand">
<TextBlock Text="{Binding Title}" <Panel>
TextTrimming="CharacterEllipsis" <Border Background="{DynamicResource SurfaceBrush}"
ToolTip.Tip="{Binding Title}" BorderBrush="{DynamicResource LineBrush}"
Foreground="{DynamicResource TextDimBrush}" /> BorderThickness="1" CornerRadius="6" />
<TextBlock Classes="meta" <Border Background="{DynamicResource RunningTintBrush}"
Text="{loc:Tr missionControl.blocked}" BorderBrush="{DynamicResource RunningTintBorderBrush}"
IsVisible="{Binding IsBlocked}" BorderThickness="1" CornerRadius="6"
Foreground="{DynamicResource AmberBrush}" /> IsVisible="{Binding IsRunning}" />
</StackPanel> <StackPanel Margin="8,6" Spacing="2">
</Border> <TextBlock Text="{Binding Title}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Title}"
Foreground="{DynamicResource TextDimBrush}" />
<TextBlock Classes="meta"
Text="{loc:Tr missionControl.blocked}"
IsVisible="{Binding IsBlocked}"
Foreground="{DynamicResource AmberBrush}" />
<TextBlock Classes="meta"
Text="{loc:Tr missionControl.running}"
IsVisible="{Binding IsRunning}"
Foreground="{DynamicResource StatusRunningBrush}" />
</StackPanel>
</Panel>
</Button>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
+2 -2
View File
@@ -40,7 +40,7 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `
- `RunHistoryMcpTools``ListRuns`, `GetRun`, `GetTaskLog` (latest run's log, tail-capped at 256 KB) - `RunHistoryMcpTools``ListRuns`, `GetRun`, `GetTaskLog` (latest run's log, tail-capped at 256 KB)
- `AgentMcpTools``ListAgents` - `AgentMcpTools``ListAgents`
- `LifecycleMcpTools``ResetFailedTask` - `LifecycleMcpTools``ResetFailedTask`
- `AppSettingsMcpTools``GetAppSettings` (read-only) - `AppSettingsMcpTools``GetAppSettings` (read-only; includes `MaxParallelExecutions`)
- `AttachmentMcpTools``AddTaskAttachment(taskId, fileName, textContent?|base64Content?)`, `ListTaskAttachments`, `RemoveTaskAttachment`. Re-attaching the same fileName overwrites; add/remove refuse on a Running task. - `AttachmentMcpTools``AddTaskAttachment(taskId, fileName, textContent?|base64Content?)`, `ListTaskAttachments`, `RemoveTaskAttachment`. Re-attaching the same fileName overwrites; add/remove refuse on a Running task.
- `ExternalMcpService` also exposes two daily-prep tools: - `ExternalMcpService` also exposes two daily-prep tools:
- `GetDailyPrepCandidates` — returns 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` helper (same file). - `GetDailyPrepCandidates` — returns 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` helper (same file).
@@ -156,7 +156,7 @@ Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`
- Review/merge: `ApproveReview(taskId, targetBranch) -> MergeResultDto` (childless task: merges its worktree then Done, conflict stays WaitingForReview; task with children: drives `PlanningMergeOrchestrator` to merge the whole unit), `ContinuePlanningMerge` / `AbortPlanningMerge` (resolve a unit-merge conflict), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `MergeTask`, `GetMergeTargets` - Review/merge: `ApproveReview(taskId, targetBranch) -> MergeResultDto` (childless task: merges its worktree then Done, conflict stays WaitingForReview; task with children: drives `PlanningMergeOrchestrator` to merge the whole unit), `ContinuePlanningMerge` / `AbortPlanningMerge` (resolve a unit-merge conflict), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `MergeTask`, `GetMergeTargets`
- Single-task conflict resolver (Layer C): `StartConflictMerge`, `GetMergeConflictDocuments` (segments), `WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` (service-level `TaskMergeService.ContinueMergeAsync`/`AbortMergeAsync` keep their names) - Single-task conflict resolver (Layer C): `StartConflictMerge`, `GetMergeConflictDocuments` (segments), `WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` (service-level `TaskMergeService.ContinueMergeAsync`/`AbortMergeAsync` keep their names)
- Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`, `FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`, `GetPlanningAggregate` (per-subtask diffs), `BuildPlanningIntegrationBranch` (combined diff) - Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`, `FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`, `GetPlanningAggregate` (per-subtask diffs), `BuildPlanningIntegrationBranch` (combined diff)
- Interactive sessions (embedded ConPTY, UI process): `ResumeTaskInTerminal` (pick-up-in-terminal), `GetInteractiveLaunchSpec`, `GetAdHocLaunchSpec`. **Every** ConPTY spec that `InteractiveLaunchSpecService` builds leads with `--effort <level>` from the relevant model's preset (task/list model for a task session, `PlanningAlias` for planning, list config for the list handler, global default for ad-hoc). `--model` is deliberately NOT forced on an interactive session — the user can still switch models in the TUI. - Interactive sessions (embedded ConPTY, UI process): `ResumeTaskInTerminal` (pick-up-in-terminal), `GetInteractiveLaunchSpec`, `GetAdHocLaunchSpec`. **Every** ConPTY spec that `InteractiveLaunchSpecService` builds leads with `--effort <level>` from the relevant model's preset (task/list model for a task session, `PlanningAlias` for planning, list config for the list handler, global default for ad-hoc). `--model` is deliberately NOT forced on an interactive session — the user can still switch models in the TUI. The list-handler spec (`BuildForMergeHelperAsync`) uses `--permission-mode auto` so it runs unattended; the `--allowedTools` allowlist (`mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill`) remains the security boundary.
- Worktrees: `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `GetWorktreesOverview`, `SetWorktreeState`, `ForceRemoveWorktree` - Worktrees: `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `GetWorktreesOverview`, `SetWorktreeState`, `ForceRemoveWorktree`
- Agents/settings/lists: `GetAgents`, `RefreshAgents`, `RestoreDefaultAgents`, `GetAppSettings`, `UpdateAppSettings`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings` - Agents/settings/lists: `GetAgents`, `RefreshAgents`, `RestoreDefaultAgents`, `GetAppSettings`, `UpdateAppSettings`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`
- Reports/notes/prep: `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`, `ListPrimeSchedules`, `UpsertPrimeSchedule`, `DeletePrimeSchedule` - Reports/notes/prep: `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`, `ListPrimeSchedules`, `UpsertPrimeSchedule`, `DeletePrimeSchedule`
+3 -1
View File
@@ -8,6 +8,7 @@ namespace ClaudeDo.Worker.External;
public sealed record AppSettingsReadDto( public sealed record AppSettingsReadDto(
string DefaultModel, int DefaultMaxTurns, string DefaultPermissionMode, string DefaultModel, int DefaultMaxTurns, string DefaultPermissionMode,
int MaxParallelExecutions,
string WorktreeStrategy, string? CentralWorktreeRoot, string WorktreeStrategy, string? CentralWorktreeRoot,
bool WorktreeAutoCleanupEnabled, int WorktreeAutoCleanupDays); bool WorktreeAutoCleanupEnabled, int WorktreeAutoCleanupDays);
@@ -18,13 +19,14 @@ public sealed class AppSettingsMcpTools
public AppSettingsMcpTools(IDbContextFactory<ClaudeDoDbContext> dbFactory) => _dbFactory = dbFactory; public AppSettingsMcpTools(IDbContextFactory<ClaudeDoDbContext> dbFactory) => _dbFactory = dbFactory;
[McpServerTool, Description("Read the worker's app-level defaults (model, max turns, permission mode, worktree strategy). Read-only.")] [McpServerTool, Description("Read the worker's app-level defaults (model, max turns, permission mode, max parallel execution slots, worktree strategy). Read-only.")]
public async Task<AppSettingsReadDto> GetAppSettings(CancellationToken cancellationToken) public async Task<AppSettingsReadDto> GetAppSettings(CancellationToken cancellationToken)
{ {
using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var row = await new AppSettingsRepository(ctx).GetAsync(cancellationToken); var row = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
return new AppSettingsReadDto( return new AppSettingsReadDto(
row.DefaultModel, row.DefaultMaxTurns, row.DefaultPermissionMode, row.DefaultModel, row.DefaultMaxTurns, row.DefaultPermissionMode,
row.MaxParallelExecutions,
row.WorktreeStrategy, row.CentralWorktreeRoot, row.WorktreeStrategy, row.CentralWorktreeRoot,
row.WorktreeAutoCleanupEnabled, row.WorktreeAutoCleanupDays); row.WorktreeAutoCleanupEnabled, row.WorktreeAutoCleanupDays);
} }
@@ -232,7 +232,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
var args = new List<string> var args = new List<string>
{ {
"--effort", EffortFor(settings, listConfig?.Model), "--effort", EffortFor(settings, listConfig?.Model),
"--permission-mode", "default", "--permission-mode", "auto",
"--allowedTools", MergeHelperAllowedTools, "--allowedTools", MergeHelperAllowedTools,
"--add-dir", sessionDir, repoDir, "--add-dir", sessionDir, repoDir,
"--append-system-prompt-file", systemPromptPath, "--append-system-prompt-file", systemPromptPath,
@@ -0,0 +1,143 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Tests;
public sealed class MoveToListTests : IDisposable
{
private readonly string _dbPath;
private readonly ClaudeDoDbContext _ctx;
public MoveToListTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_movelist_{Guid.NewGuid():N}.db");
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={_dbPath}")
.Options;
_ctx = new ClaudeDoDbContext(options);
_ctx.Database.EnsureCreated();
}
public void Dispose()
{
_ctx.Dispose();
try { File.Delete(_dbPath); } catch { }
try { File.Delete(_dbPath + "-wal"); } catch { }
try { File.Delete(_dbPath + "-shm"); } catch { }
}
private async Task SeedListsAsync(params string[] ids)
{
foreach (var id in ids)
_ctx.Lists.Add(new ListEntity { Id = id, Name = id, CreatedAt = DateTime.UtcNow });
await _ctx.SaveChangesAsync();
}
[Fact]
public async Task MoveToList_changes_ListId_and_appends_at_end_of_target()
{
await SeedListsAsync("source", "target");
_ctx.Tasks.Add(new TaskEntity
{
Id = "t1", ListId = "source", Title = "Task", CreatedAt = DateTime.UtcNow, SortOrder = 0,
});
// Existing tasks already in the target list, so we can assert the moved task lands after them.
_ctx.Tasks.Add(new TaskEntity
{
Id = "existing1", ListId = "target", Title = "Existing 1", CreatedAt = DateTime.UtcNow, SortOrder = 0,
});
_ctx.Tasks.Add(new TaskEntity
{
Id = "existing2", ListId = "target", Title = "Existing 2", CreatedAt = DateTime.UtcNow, SortOrder = 1,
});
await _ctx.SaveChangesAsync();
_ctx.ChangeTracker.Clear();
await new TaskRepository(_ctx).MoveToListAsync("t1", "target");
var moved = await _ctx.Tasks.AsNoTracking().FirstAsync(t => t.Id == "t1");
Assert.Equal("target", moved.ListId);
Assert.Equal(2, moved.SortOrder);
}
[Fact]
public async Task MoveToList_moves_all_descendants_recursively()
{
await SeedListsAsync("source", "target");
_ctx.Tasks.Add(new TaskEntity
{
Id = "parent", ListId = "source", Title = "Parent", CreatedAt = DateTime.UtcNow, SortOrder = 0,
});
_ctx.Tasks.Add(new TaskEntity
{
Id = "child", ListId = "source", Title = "Child", CreatedAt = DateTime.UtcNow,
SortOrder = 1, ParentTaskId = "parent",
});
_ctx.Tasks.Add(new TaskEntity
{
Id = "grandchild", ListId = "source", Title = "Grandchild", CreatedAt = DateTime.UtcNow,
SortOrder = 2, ParentTaskId = "child",
});
await _ctx.SaveChangesAsync();
_ctx.ChangeTracker.Clear();
await new TaskRepository(_ctx).MoveToListAsync("parent", "target");
var all = await _ctx.Tasks.AsNoTracking()
.Where(t => t.Id == "parent" || t.Id == "child" || t.Id == "grandchild")
.ToListAsync();
Assert.All(all, t => Assert.Equal("target", t.ListId));
}
[Fact]
public async Task MoveToList_first_task_in_empty_target_gets_SortOrder_zero()
{
await SeedListsAsync("source", "target");
_ctx.Tasks.Add(new TaskEntity
{
Id = "t1", ListId = "source", Title = "Task", CreatedAt = DateTime.UtcNow, SortOrder = 3,
});
await _ctx.SaveChangesAsync();
_ctx.ChangeTracker.Clear();
await new TaskRepository(_ctx).MoveToListAsync("t1", "target");
var moved = await _ctx.Tasks.AsNoTracking().FirstAsync(t => t.Id == "t1");
Assert.Equal("target", moved.ListId);
Assert.Equal(0, moved.SortOrder);
}
// A corrupt parent chain must not hang the descendant walk.
[Fact(Timeout = 15000)]
public async Task MoveToList_survives_a_self_parenting_task()
{
await SeedListsAsync("source", "target");
_ctx.Tasks.Add(new TaskEntity
{
Id = "t1", ListId = "source", Title = "Task", CreatedAt = DateTime.UtcNow, SortOrder = 0,
});
await _ctx.SaveChangesAsync();
// Set the cycle after insert — the FK tolerates it, the walk must too.
await _ctx.Tasks.Where(t => t.Id == "t1")
.ExecuteUpdateAsync(s => s.SetProperty(t => t.ParentTaskId, "t1"));
_ctx.ChangeTracker.Clear();
await new TaskRepository(_ctx).MoveToListAsync("t1", "target");
var moved = await _ctx.Tasks.AsNoTracking().FirstAsync(t => t.Id == "t1");
Assert.Equal("target", moved.ListId);
}
[Fact]
public async Task MoveToList_throws_when_task_missing()
{
await SeedListsAsync("source", "target");
await Assert.ThrowsAsync<InvalidOperationException>(() =>
new TaskRepository(_ctx).MoveToListAsync("nope", "target"));
}
}
@@ -10,6 +10,7 @@ public sealed class DownloadAndExtractStepTests : IDisposable
{ {
private readonly string _tempDir; private readonly string _tempDir;
private readonly string _installDir; private readonly string _installDir;
private readonly string _cacheDir;
public DownloadAndExtractStepTests() public DownloadAndExtractStepTests()
{ {
@@ -17,6 +18,7 @@ public sealed class DownloadAndExtractStepTests : IDisposable
Directory.CreateDirectory(_tempDir); Directory.CreateDirectory(_tempDir);
_installDir = Path.Combine(_tempDir, "install"); _installDir = Path.Combine(_tempDir, "install");
Directory.CreateDirectory(_installDir); Directory.CreateDirectory(_installDir);
_cacheDir = Path.Combine(_tempDir, "cache");
} }
public void Dispose() public void Dispose()
@@ -28,6 +30,7 @@ public sealed class DownloadAndExtractStepTests : IDisposable
{ {
private readonly Dictionary<string, string> _urlToSourceFile; private readonly Dictionary<string, string> _urlToSourceFile;
public GiteaRelease? Release { get; set; } public GiteaRelease? Release { get; set; }
public List<string> DownloadedUrls { get; } = new();
public FileCopyReleaseClient(Dictionary<string, string> urlToSourceFile) public FileCopyReleaseClient(Dictionary<string, string> urlToSourceFile)
=> _urlToSourceFile = urlToSourceFile; => _urlToSourceFile = urlToSourceFile;
@@ -36,12 +39,45 @@ public sealed class DownloadAndExtractStepTests : IDisposable
public Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct) public Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct)
{ {
DownloadedUrls.Add(url);
File.Copy(_urlToSourceFile[url], destPath, overwrite: true); File.Copy(_urlToSourceFile[url], destPath, overwrite: true);
progress.Report(new FileInfo(destPath).Length); progress.Report(new FileInfo(destPath).Length);
return Task.CompletedTask; return Task.CompletedTask;
} }
} }
// Builds a valid release zip + checksums.txt and a client that serves both.
private (FileCopyReleaseClient Client, string ZipName) SetUpRelease()
{
var zipPath = Path.Combine(_tempDir, "release.zip");
using (var fs = File.Create(zipPath))
using (var zip = new ZipArchive(fs, ZipArchiveMode.Create))
{
var a = zip.CreateEntry("app/a.txt");
using (var w = new StreamWriter(a.Open())) w.Write("hello-app");
var b = zip.CreateEntry("worker/b.txt");
using (var w = new StreamWriter(b.Open())) w.Write("hello-worker");
}
const string zipName = "ClaudeDo-0.1.0-win-x64.zip";
var checksumsPath = Path.Combine(_tempDir, "checksums.txt");
File.WriteAllText(checksumsPath, $"{ChecksumVerifier.ComputeSha256(zipPath)} {zipName}\n");
var release = new GiteaRelease("v0.1.0", "v0.1.0", new[]
{
new ReleaseAsset(zipName, "fake://zip", new FileInfo(zipPath).Length),
new ReleaseAsset("checksums.txt", "fake://checksums", new FileInfo(checksumsPath).Length),
});
var client = new FileCopyReleaseClient(new()
{
["fake://zip"] = zipPath,
["fake://checksums"] = checksumsPath,
}) { Release = release };
return (client, zipName);
}
[Fact] [Fact]
public async Task Extracts_Zip_Into_InstallDir_App_And_Worker() public async Task Extracts_Zip_Into_InstallDir_App_And_Worker()
{ {
@@ -71,7 +107,7 @@ public sealed class DownloadAndExtractStepTests : IDisposable
["fake://checksums"] = checksumsPath, ["fake://checksums"] = checksumsPath,
}) { Release = release }; }) { Release = release };
var step = new DownloadAndExtractStep(client); var step = new DownloadAndExtractStep(client, _cacheDir);
var ctx = new InstallContext { InstallDirectory = _installDir }; var ctx = new InstallContext { InstallDirectory = _installDir };
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None); var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
@@ -110,7 +146,7 @@ public sealed class DownloadAndExtractStepTests : IDisposable
["fake://checksums"] = checksumsPath, ["fake://checksums"] = checksumsPath,
}) { Release = release }; }) { Release = release };
var step = new DownloadAndExtractStep(client); var step = new DownloadAndExtractStep(client, _cacheDir);
var ctx = new InstallContext { InstallDirectory = _installDir }; var ctx = new InstallContext { InstallDirectory = _installDir };
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None); var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
@@ -126,7 +162,7 @@ public sealed class DownloadAndExtractStepTests : IDisposable
{ {
var release = new GiteaRelease("v0.1.0", "v0.1.0", Array.Empty<ReleaseAsset>()); var release = new GiteaRelease("v0.1.0", "v0.1.0", Array.Empty<ReleaseAsset>());
var client = new FileCopyReleaseClient(new()) { Release = release }; var client = new FileCopyReleaseClient(new()) { Release = release };
var step = new DownloadAndExtractStep(client); var step = new DownloadAndExtractStep(client, _cacheDir);
var ctx = new InstallContext { InstallDirectory = _installDir }; var ctx = new InstallContext { InstallDirectory = _installDir };
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None); var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
@@ -135,11 +171,92 @@ public sealed class DownloadAndExtractStepTests : IDisposable
Assert.Contains("not found", result.ErrorMessage!, StringComparison.OrdinalIgnoreCase); Assert.Contains("not found", result.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
} }
[Fact]
public async Task Reuses_Cached_Zip_Instead_Of_Downloading_It_Again()
{
var (client, zipName) = SetUpRelease();
Directory.CreateDirectory(_cacheDir);
// A previous run left a fully downloaded, still-valid zip behind.
File.Copy(Path.Combine(_tempDir, "release.zip"), Path.Combine(_cacheDir, zipName));
var step = new DownloadAndExtractStep(client, _cacheDir);
var ctx = new InstallContext { InstallDirectory = _installDir };
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
Assert.True(result.Success, result.ErrorMessage);
Assert.DoesNotContain("fake://zip", client.DownloadedUrls);
Assert.Equal("hello-app", File.ReadAllText(Path.Combine(_installDir, "app", "a.txt")));
}
[Fact]
public async Task Redownloads_When_Cached_Zip_Is_Corrupt()
{
var (client, zipName) = SetUpRelease();
Directory.CreateDirectory(_cacheDir);
// A cancelled/aborted download left a truncated file behind.
File.WriteAllText(Path.Combine(_cacheDir, zipName), "not-a-zip");
var step = new DownloadAndExtractStep(client, _cacheDir);
var ctx = new InstallContext { InstallDirectory = _installDir };
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
Assert.True(result.Success, result.ErrorMessage);
Assert.Contains("fake://zip", client.DownloadedUrls);
Assert.Equal("hello-app", File.ReadAllText(Path.Combine(_installDir, "app", "a.txt")));
}
[Fact]
public async Task Keeps_Zip_Cached_When_The_Install_Fails_And_Drops_It_On_Success()
{
var (client, zipName) = SetUpRelease();
var cachedZip = Path.Combine(_cacheDir, zipName);
// Make extraction fail: a *file* named "app" blocks the app/ entry's directory.
var blocker = Path.Combine(_installDir, "app");
File.WriteAllText(blocker, "blocked");
var step = new DownloadAndExtractStep(client, _cacheDir);
var ctx = new InstallContext { InstallDirectory = _installDir };
var failed = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
Assert.False(failed.Success);
Assert.True(File.Exists(cachedZip), "a failed install must keep the download cached");
// Retry after clearing the blocker: no second download, and the cache is dropped.
File.Delete(blocker);
client.DownloadedUrls.Clear();
var ok = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
Assert.True(ok.Success, ok.ErrorMessage);
Assert.DoesNotContain("fake://zip", client.DownloadedUrls);
Assert.False(File.Exists(cachedZip), "a successful install must not leave the zip behind");
}
[Fact]
public async Task Prunes_Cached_Zips_From_Other_Versions()
{
var (client, _) = SetUpRelease();
Directory.CreateDirectory(_cacheDir);
var stale = Path.Combine(_cacheDir, "ClaudeDo-0.0.9-win-x64.zip");
File.WriteAllText(stale, "old release");
var step = new DownloadAndExtractStep(client, _cacheDir);
var ctx = new InstallContext { InstallDirectory = _installDir };
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
Assert.True(result.Success, result.ErrorMessage);
Assert.False(File.Exists(stale));
}
[Fact] [Fact]
public async Task Fails_When_ReleaseClient_Returns_Null() public async Task Fails_When_ReleaseClient_Returns_Null()
{ {
var client = new FileCopyReleaseClient(new()) { Release = null }; var client = new FileCopyReleaseClient(new()) { Release = null };
var step = new DownloadAndExtractStep(client); var step = new DownloadAndExtractStep(client, _cacheDir);
var ctx = new InstallContext { InstallDirectory = _installDir }; var ctx = new InstallContext { InstallDirectory = _installDir };
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None); var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
@@ -21,11 +21,14 @@ public class DiffViewerViewModelTests
{ {
public IReadOnlyList<SubtaskDiffDto> AggregateResult { get; set; } = Array.Empty<SubtaskDiffDto>(); public IReadOnlyList<SubtaskDiffDto> AggregateResult { get; set; } = Array.Empty<SubtaskDiffDto>();
public CombinedDiffResultDto? CombinedResult { get; set; } public CombinedDiffResultDto? CombinedResult { get; set; }
public string? CombinedException { get; set; }
public override Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId) => public override Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId) =>
Task.FromResult(AggregateResult); Task.FromResult(AggregateResult);
public override Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) => public override Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) =>
Task.FromResult(CombinedResult); CombinedException is not null
? throw new Exception(CombinedException)
: Task.FromResult(CombinedResult);
} }
// ── Files mode: commit-range guards (ported from DiffModal) ── // ── Files mode: commit-range guards (ported from DiffModal) ──
@@ -193,4 +196,26 @@ public class DiffViewerViewModelTests
Assert.NotNull(vm.CombinedWarning); Assert.NotNull(vm.CombinedWarning);
Assert.NotEmpty(vm.CombinedWarning!); Assert.NotEmpty(vm.CombinedWarning!);
} }
[Fact]
public async Task Planning_ToggleCombined_WhenWorkerThrows_ShowsExceptionMessage()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") },
CombinedException = "planning task not found",
};
var vm = new DiffViewerViewModel(null!, fake);
vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync();
vm.IsCombinedMode = true;
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && vm.IsLoadingCombined) await Task.Delay(10);
Assert.NotNull(vm.CombinedWarning);
Assert.Contains("planning task not found", vm.CombinedWarning);
Assert.Equal("", vm.DisplayedDiff);
}
} }
@@ -0,0 +1,38 @@
using System.IO;
using ClaudeDo.Localization;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class FilesSettingsTabViewModelTests
{
public FilesSettingsTabViewModelTests()
{
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");
}
private sealed class ThrowingWorker : StubWorkerClient
{
public string ExceptionMessage { get; init; } = "permission denied copying agent files";
public override Task<SeedResultDto?> RestoreDefaultAgentsAsync() =>
throw new Exception(ExceptionMessage);
}
[Fact]
public async Task RestoreDefaultAgents_WhenWorkerThrows_ShowsExceptionMessage_NotGenericOffline()
{
var worker = new ThrowingWorker();
var vm = new FilesSettingsTabViewModel(worker);
await vm.RestoreDefaultAgentsCommand.ExecuteAsync(null);
Assert.Contains(worker.ExceptionMessage, vm.StatusMessage);
Assert.False(vm.IsBusy);
}
}
@@ -49,8 +49,10 @@ public class MissionControlViewModelTests : IDisposable
private MissionControlViewModel BuildVm(StubWorkerClient worker) private MissionControlViewModel BuildVm(StubWorkerClient worker)
=> new MissionControlViewModel(new TestDbFactory(NewContext), worker); => new MissionControlViewModel(new TestDbFactory(NewContext), worker);
// ── acceptance criterion (a): TaskStarted must NOT add a pane ──────────────
[Fact] [Fact]
public void TwoStarts_CreateTwoMonitors_ColumnCountTwo() public void TaskStarted_DoesNotAddPane()
{ {
var worker = new FakeWorker(); var worker = new FakeWorker();
using var vm = BuildVm(worker); using var vm = BuildVm(worker);
@@ -58,18 +60,140 @@ public class MissionControlViewModelTests : IDisposable
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow);
worker.RaiseTaskStarted("slot-2", "t2", DateTime.UtcNow); worker.RaiseTaskStarted("slot-2", "t2", DateTime.UtcNow);
Assert.Empty(vm.Monitors);
Assert.Empty(vm.Panes);
}
[Fact]
public void Construction_DoesNotSeedMonitorsEvenWithActiveTasks()
{
var worker = new SeededFakeWorker();
using var vm = BuildVm(worker);
Assert.Empty(vm.Monitors);
Assert.Empty(vm.Panes);
}
private sealed class SeededFakeWorker : StubWorkerClient
{
public override IReadOnlyList<ActiveTask> GetActiveTasks()
=> new[] { new ActiveTask("slot-1", "seed1", DateTime.UtcNow) };
}
// ── acceptance criterion (b): RefreshQueueAsync returns Running rows first ─
[Fact]
public async Task RefreshQueueAsync_RunningFirst_WithIsRunningSet()
{
await using (var db = NewContext())
{
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
// Running task has higher SortOrder — must still appear first
db.Tasks.Add(new TaskEntity { Id = "r1", ListId = "L1", Title = "running-task", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 10 });
db.Tasks.Add(new TaskEntity { Id = "q1", ListId = "L1", Title = "queued-task", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
await db.SaveChangesAsync();
}
var worker = new FakeWorker();
using var vm = BuildVm(worker);
await vm.RefreshQueueAsync();
Assert.True(vm.HasQueued);
Assert.Equal(2, vm.Queued.Count);
Assert.Equal("r1", vm.Queued[0].Id);
Assert.True(vm.Queued[0].IsRunning);
Assert.Equal("q1", vm.Queued[1].Id);
Assert.False(vm.Queued[1].IsRunning);
}
[Fact]
public async Task HasQueued_TrueWhenOnlyRunningTasksExist()
{
await using (var db = NewContext())
{
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
db.Tasks.Add(new TaskEntity { Id = "r1", ListId = "L1", Title = "running", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
await db.SaveChangesAsync();
}
var worker = new FakeWorker();
using var vm = BuildVm(worker);
await vm.RefreshQueueAsync();
Assert.True(vm.HasQueued);
}
// ── acceptance criterion (c): row click invokes OpenInApp ──────────────────
[Fact]
public async Task Row_Click_InvokesOpenInApp()
{
await using (var db = NewContext())
{
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
db.Tasks.Add(new TaskEntity { Id = "q1", ListId = "L1", Title = "clickable", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
await db.SaveChangesAsync();
}
var worker = new FakeWorker();
using var vm = BuildVm(worker);
string? opened = null;
vm.OpenInApp = id => opened = id;
await vm.RefreshQueueAsync();
vm.Queued[0].OpenInAppCommand!.Execute(null);
Assert.Equal("q1", opened);
}
[Fact]
public async Task Row_Click_RunningTask_InvokesOpenInApp()
{
await using (var db = NewContext())
{
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
db.Tasks.Add(new TaskEntity { Id = "r1", ListId = "L1", Title = "running", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
await db.SaveChangesAsync();
}
var worker = new FakeWorker();
using var vm = BuildVm(worker);
string? opened = null;
vm.OpenInApp = id => opened = id;
await vm.RefreshQueueAsync();
vm.Queued[0].OpenInAppCommand!.Execute(null);
Assert.Equal("r1", opened);
}
// ── monitor machinery tests (EnsureMonitor still works when called directly) ─
[Fact]
public void EnsureMonitor_TwoCalls_CreateTwoMonitors_ColumnCountTwo()
{
var worker = new FakeWorker();
using var vm = BuildVm(worker);
vm.EnsureMonitor("t1");
vm.EnsureMonitor("t2");
Assert.Equal(2, vm.Monitors.Count); Assert.Equal(2, vm.Monitors.Count);
Assert.Equal(2, vm.ColumnCount); Assert.Equal(2, vm.ColumnCount);
} }
[Fact] [Fact]
public void DuplicateStart_DoesNotAddSecondMonitor() public void EnsureMonitor_DuplicateCall_DoesNotAddSecondMonitor()
{ {
var worker = new FakeWorker(); var worker = new FakeWorker();
using var vm = BuildVm(worker); using var vm = BuildVm(worker);
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); vm.EnsureMonitor("t1");
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); vm.EnsureMonitor("t1");
Assert.Equal(1, vm.Monitors.Count); Assert.Equal(1, vm.Monitors.Count);
} }
@@ -80,7 +204,7 @@ public class MissionControlViewModelTests : IDisposable
var worker = new FakeWorker(); var worker = new FakeWorker();
using var vm = BuildVm(worker); using var vm = BuildVm(worker);
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); vm.EnsureMonitor("t1");
worker.RaiseTaskFinished("slot-1", "t1", "done", DateTime.UtcNow); worker.RaiseTaskFinished("slot-1", "t1", "done", DateTime.UtcNow);
Assert.Equal(1, vm.Monitors.Count); Assert.Equal(1, vm.Monitors.Count);
@@ -93,8 +217,8 @@ public class MissionControlViewModelTests : IDisposable
var worker = new FakeWorker(); var worker = new FakeWorker();
using var vm = BuildVm(worker); using var vm = BuildVm(worker);
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); vm.EnsureMonitor("t1");
worker.RaiseTaskStarted("slot-2", "t2", DateTime.UtcNow); vm.EnsureMonitor("t2");
worker.RaiseTaskFinished("slot-1", "t1", "done", DateTime.UtcNow); worker.RaiseTaskFinished("slot-1", "t1", "done", DateTime.UtcNow);
vm.ClearFinishedCommand.Execute(null); vm.ClearFinishedCommand.Execute(null);
@@ -104,22 +228,6 @@ public class MissionControlViewModelTests : IDisposable
Assert.Equal(1, vm.ColumnCount); Assert.Equal(1, vm.ColumnCount);
} }
[Fact]
public void SeedsFromActiveTasksOnConstruction()
{
var worker = new SeededFakeWorker();
using var vm = BuildVm(worker);
Assert.Equal(1, vm.Monitors.Count);
Assert.Equal("seed1", vm.Monitors[0].SubscribedTaskId);
}
private sealed class SeededFakeWorker : StubWorkerClient
{
public override IReadOnlyList<ActiveTask> GetActiveTasks()
=> new[] { new ActiveTask("slot-1", "seed1", DateTime.UtcNow) };
}
[Fact] [Fact]
public void OpenInApp_PropagatesToMonitors_AndCommandInvokesHook() public void OpenInApp_PropagatesToMonitors_AndCommandInvokesHook()
{ {
@@ -129,7 +237,7 @@ public class MissionControlViewModelTests : IDisposable
string? revealed = null; string? revealed = null;
vm.OpenInApp = id => revealed = id; vm.OpenInApp = id => revealed = id;
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); vm.EnsureMonitor("t1");
vm.Monitors[0].OpenInAppCommand.Execute(null); vm.Monitors[0].OpenInAppCommand.Execute(null);
@@ -145,7 +253,7 @@ public class MissionControlViewModelTests : IDisposable
Action? reDock = null; Action? reDock = null;
vm.ShowDetached = (m, rd) => { detached = m; reDock = rd; }; vm.ShowDetached = (m, rd) => { detached = m; reDock = rd; };
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); vm.EnsureMonitor("t1");
var monitor = vm.Monitors[0]; var monitor = vm.Monitors[0];
monitor.DetachCommand.Execute(null); monitor.DetachCommand.Execute(null);
@@ -162,7 +270,7 @@ public class MissionControlViewModelTests : IDisposable
{ {
var worker = new FakeWorker(); var worker = new FakeWorker();
using var vm = BuildVm(worker); using var vm = BuildVm(worker);
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); vm.EnsureMonitor("t1");
worker.RaiseTaskFinished("slot-1", "t1", "waiting_for_review", DateTime.UtcNow); worker.RaiseTaskFinished("slot-1", "t1", "waiting_for_review", DateTime.UtcNow);
Assert.True(vm.Monitors[0].IsWaitingForReview); Assert.True(vm.Monitors[0].IsWaitingForReview);
@@ -177,7 +285,7 @@ public class MissionControlViewModelTests : IDisposable
using var vm = BuildVm(worker); using var vm = BuildVm(worker);
Action? reDock = null; Action? reDock = null;
vm.ShowDetached = (m, rd) => reDock = rd; vm.ShowDetached = (m, rd) => reDock = rd;
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); vm.EnsureMonitor("t1");
var monitor = vm.Monitors[0]; var monitor = vm.Monitors[0];
monitor.DetachCommand.Execute(null); monitor.DetachCommand.Execute(null);
@@ -195,7 +303,7 @@ public class MissionControlViewModelTests : IDisposable
var worker = new FakeWorker(); var worker = new FakeWorker();
using var vm = BuildVm(worker); using var vm = BuildVm(worker);
vm.ShowDetached = (m, rd) => { }; vm.ShowDetached = (m, rd) => { };
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); vm.EnsureMonitor("t1");
var monitor = vm.Monitors[0]; var monitor = vm.Monitors[0];
var closeRequested = false; var closeRequested = false;
@@ -210,9 +318,9 @@ public class MissionControlViewModelTests : IDisposable
{ {
var worker = new FakeWorker(); var worker = new FakeWorker();
using var vm = BuildVm(worker); using var vm = BuildVm(worker);
worker.RaiseTaskStarted("s1", "t1", DateTime.UtcNow); vm.EnsureMonitor("t1");
worker.RaiseTaskStarted("s2", "t2", DateTime.UtcNow); vm.EnsureMonitor("t2");
worker.RaiseTaskStarted("s3", "t3", DateTime.UtcNow); vm.EnsureMonitor("t3");
vm.MoveMonitor(vm.Monitors[0], vm.Monitors[2]); // move t1 to t3's slot vm.MoveMonitor(vm.Monitors[0], vm.Monitors[2]); // move t1 to t3's slot
Assert.Equal(new[] { "t2", "t3", "t1" }, vm.Monitors.Select(m => m.SubscribedTaskId).ToArray()); Assert.Equal(new[] { "t2", "t3", "t1" }, vm.Monitors.Select(m => m.SubscribedTaskId).ToArray());
@@ -320,7 +428,7 @@ public class MissionControlViewModelTests : IDisposable
var worker = new FakeWorker(); var worker = new FakeWorker();
using var vm = BuildVm(worker); using var vm = BuildVm(worker);
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); vm.EnsureMonitor("t1");
await vm.OpenConPtySessionAsync("t2"); await vm.OpenConPtySessionAsync("t2");
Assert.Equal(2, vm.Panes.Count); Assert.Equal(2, vm.Panes.Count);
@@ -27,6 +27,33 @@ public class NotesEditorViewModelTests
public Task DeleteAsync(string id) { Store.RemoveAll(n => n.Id == id); return Task.CompletedTask; } public Task DeleteAsync(string id) { Store.RemoveAll(n => n.Id == id); return Task.CompletedTask; }
} }
private sealed class ThrowingNotes : INotesApi
{
public string ExceptionMessage { get; init; } = "worker offline";
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>());
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => throw new Exception(ExceptionMessage);
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
public Task DeleteAsync(string id) => Task.CompletedTask;
}
[Fact]
public async Task AddBullet_WhenApiThrows_RaisesErrorReported_AndKeepsDraftText()
{
var api = new ThrowingNotes();
var vm = new NotesEditorViewModel(api);
await vm.LoadDayAsync(new DateOnly(2026, 6, 1));
string? reportedError = null;
vm.ErrorReported += msg => reportedError = msg;
vm.NewBulletText = "Standup vorbereitet";
await vm.AddBulletCommand.ExecuteAsync(null);
Assert.Equal(api.ExceptionMessage, reportedError);
Assert.Empty(vm.Bullets);
Assert.Equal("Standup vorbereitet", vm.NewBulletText);
}
[Fact] [Fact]
public async Task AddBullet_PersistsAndAppears_ForCurrentDay() public async Task AddBullet_PersistsAndAppears_ForCurrentDay()
{ {
@@ -19,6 +19,14 @@ public class PrimeClaudeTabViewModelTests
public Task DeleteAsync(Guid id) { Deletes.Add(id); return Task.CompletedTask; } public Task DeleteAsync(Guid id) { Deletes.Add(id); return Task.CompletedTask; }
} }
private sealed class ThrowingApi : IPrimeScheduleApi
{
public string ExceptionMessage { get; init; } = "worker offline";
public Task<List<PrimeScheduleDto>> ListAsync() => Task.FromResult(new List<PrimeScheduleDto>());
public Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto) => throw new Exception(ExceptionMessage);
public Task DeleteAsync(Guid id) => Task.CompletedTask;
}
private static PrimeScheduleDto Dto(Guid id, int days, TimeSpan time) => private static PrimeScheduleDto Dto(Guid id, int days, TimeSpan time) =>
new(id, days, time, true, null, null); new(id, days, time, true, null, null);
@@ -93,4 +101,19 @@ public class PrimeClaudeTabViewModelTests
vm.AddScheduleCommand.Execute(null); vm.AddScheduleCommand.Execute(null);
Assert.Null(vm.Validate()); Assert.Null(vm.Validate());
} }
// SettingsModalViewModel.Save() is the only place that catches Prime.SaveAsync's
// failures (via a try/catch around the whole settings save) and surfaces them as
// "Save failed: {message}". For that to work, SaveAsync must propagate the worker's
// exception rather than swallow it.
[Fact]
public async Task Save_WhenApiThrows_PropagatesException()
{
var api = new ThrowingApi();
var vm = new PrimeClaudeTabViewModel(api);
vm.AddScheduleCommand.Execute(null);
var ex = await Assert.ThrowsAsync<Exception>(() => vm.SaveAsync());
Assert.Equal(api.ExceptionMessage, ex.Message);
}
} }
@@ -0,0 +1,112 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.ViewModels.Islands;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.Tests.ViewModels;
// Switching lists must drop the previous list's selection; reloading the SAME list (worker
// refresh / reconnect) must keep it, so a live update never yanks the detail pane away.
public class TasksIslandListSwitchSelectionTests : IDisposable
{
private readonly string _dbPath;
public TasksIslandListSwitchSelectionTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ui_listswitch_{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 sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
{
private readonly Func<ClaudeDoDbContext> _create;
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
public ClaudeDoDbContext CreateDbContext() => _create();
}
private async Task SeedTwoListsAsync()
{
await using var db = NewContext();
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
db.Lists.Add(new ListEntity { Id = "L2", Name = "Home", CreatedAt = DateTime.UtcNow });
db.Tasks.Add(new TaskEntity
{
Id = "T1", ListId = "L1", Title = "Task one",
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 0,
});
db.Tasks.Add(new TaskEntity
{
Id = "T2", ListId = "L2", Title = "Task two",
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 0,
});
await db.SaveChangesAsync();
}
private static ListNavItemViewModel Nav(string listId, string name) =>
new() { Id = $"user:{listId}", Name = name, Kind = ListKind.User };
[Fact]
public async Task LoadForList_OtherList_ClearsSelectedTask_AndRaisesSelectionChanged()
{
await SeedTwoListsAsync();
var vm = new TasksIslandViewModel(new TestDbFactory(NewContext), worker: null);
vm.LoadForList(Nav("L1", "Work"));
Assert.True(await vm.SelectByIdAsync("T1"));
var selectionChanges = 0;
vm.SelectionChanged += (_, _) => selectionChanges++;
vm.LoadForList(Nav("L2", "Home"));
if (vm.LoadTask is { } load) await load;
Assert.Null(vm.SelectedTask);
Assert.Equal(1, selectionChanges);
}
[Fact]
public async Task LoadForList_NullList_ClearsSelectedTask()
{
await SeedTwoListsAsync();
var vm = new TasksIslandViewModel(new TestDbFactory(NewContext), worker: null);
vm.LoadForList(Nav("L1", "Work"));
Assert.True(await vm.SelectByIdAsync("T1"));
vm.LoadForList(null);
Assert.Null(vm.SelectedTask);
}
[Fact]
public async Task LoadForList_SameList_KeepsSelectedTask()
{
await SeedTwoListsAsync();
var vm = new TasksIslandViewModel(new TestDbFactory(NewContext), worker: null);
vm.LoadForList(Nav("L1", "Work"));
Assert.True(await vm.SelectByIdAsync("T1"));
vm.LoadForList(Nav("L1", "Work"));
if (vm.LoadTask is { } load) await load;
Assert.Equal("T1", vm.SelectedTask?.Id);
}
}
@@ -0,0 +1,346 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Conflicts;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class TasksIslandMoveToListTests : IDisposable
{
private readonly string _dbPath;
public TasksIslandMoveToListTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_movetolist_{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 sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
{
private readonly Func<ClaudeDoDbContext> _create;
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
public ClaudeDoDbContext CreateDbContext() => _create();
}
private sealed class FakeDialogService : IDialogService
{
public bool ConfirmResult;
public int ConfirmCalls;
public string? LastMessage;
public Task<bool> ConfirmAsync(string message)
{
ConfirmCalls++;
LastMessage = message;
return Task.FromResult(ConfirmResult);
}
public Task ShowAboutAsync(AboutModalViewModel vm) => throw new NotImplementedException();
public Task ShowWeeklyReportAsync(WeeklyReportModalViewModel 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 ShowDetachedMonitor(TaskMonitorViewModel monitor, Action onClosed) => throw new NotImplementedException();
}
private TasksIslandViewModel BuildViewModel() => new(new TestDbFactory(NewContext), worker: null);
private async Task SeedListsAsync(string? sourceDir = null, string? targetDir = null)
{
await using var db = NewContext();
db.Lists.Add(new ListEntity { Id = "source", Name = "Source", CreatedAt = DateTime.UtcNow, WorkingDir = sourceDir });
db.Lists.Add(new ListEntity { Id = "target", Name = "Target", CreatedAt = DateTime.UtcNow, WorkingDir = targetDir });
await db.SaveChangesAsync();
}
private async Task SeedTaskAsync(string id, TaskStatus status, string listId = "source", string? parentTaskId = null)
{
await using var db = NewContext();
db.Tasks.Add(new TaskEntity
{
Id = id, ListId = listId, Title = id, CreatedAt = DateTime.UtcNow,
Status = status, ParentTaskId = parentTaskId, SortOrder = 0,
});
await db.SaveChangesAsync();
}
private async Task SeedWorktreeAsync(string taskId, WorktreeState state)
{
await using var db = NewContext();
db.Worktrees.Add(new WorktreeEntity
{
TaskId = taskId, Path = $"C:/wt/{taskId}", BranchName = $"task/{taskId}",
BaseCommit = "abc123", State = state, CreatedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
}
private static ListNavItemViewModel SourceList(string? workingDir = null) =>
new() { Id = "user:source", Kind = ListKind.User, Name = "Source", WorkingDir = workingDir };
private static ListNavItemViewModel TargetList(string? workingDir = null) =>
new() { Id = "user:target", Kind = ListKind.User, Name = "Target", WorkingDir = workingDir };
private static async Task LoadAndWaitAsync(TasksIslandViewModel vm, ListNavItemViewModel list)
{
vm.LoadForList(list);
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline)
{
await Task.Delay(25);
if (vm.Items.Count > 0) break;
}
await Task.Delay(50);
}
private async Task<TaskEntity> GetTaskAsync(string id)
{
await using var db = NewContext();
return await db.Tasks.AsNoTracking().FirstAsync(t => t.Id == id);
}
[Fact]
public async Task Move_RunningTask_IsRejected_AndReportsError()
{
await SeedListsAsync();
await SeedTaskAsync("t1", TaskStatus.Running);
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, SourceList());
string? error = null;
vm.ErrorReported += msg => error = msg;
var row = vm.Items.First(r => r.Id == "t1");
await vm.MoveTaskToListAsync(row, TargetList());
Assert.NotNull(error);
var entity = await GetTaskAsync("t1");
Assert.Equal("source", entity.ListId);
}
[Fact]
public async Task Move_TaskWithActiveWorktree_IsRejected_AndReportsError_EvenSameRepo()
{
var dir = "C:/repo";
await SeedListsAsync(dir, dir);
await SeedTaskAsync("t1", TaskStatus.Idle);
await SeedWorktreeAsync("t1", WorktreeState.Active);
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, SourceList(dir));
string? error = null;
vm.ErrorReported += msg => error = msg;
var row = vm.Items.First(r => r.Id == "t1");
await vm.MoveTaskToListAsync(row, TargetList(dir));
Assert.NotNull(error);
var entity = await GetTaskAsync("t1");
Assert.Equal("source", entity.ListId);
}
[Fact]
public async Task Move_DescendantWithKeptWorktree_IsRejected()
{
await SeedListsAsync();
await SeedTaskAsync("parent", TaskStatus.Idle);
await SeedTaskAsync("child", TaskStatus.Idle, parentTaskId: "parent");
await SeedWorktreeAsync("child", WorktreeState.Kept);
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, SourceList());
string? error = null;
vm.ErrorReported += msg => error = msg;
var row = vm.Items.First(r => r.Id == "parent");
await vm.MoveTaskToListAsync(row, TargetList());
Assert.NotNull(error);
var entity = await GetTaskAsync("parent");
Assert.Equal("source", entity.ListId);
}
[Fact]
public async Task Move_SameWorkingDir_MovesWithoutConfirmDialog()
{
var dir = "C:/repo";
await SeedListsAsync(dir, dir);
await SeedTaskAsync("t1", TaskStatus.Idle);
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, SourceList(dir));
var dialogs = new FakeDialogService();
vm.Dialogs = dialogs;
var row = vm.Items.First(r => r.Id == "t1");
await vm.MoveTaskToListAsync(row, TargetList(dir));
Assert.Equal(0, dialogs.ConfirmCalls);
var entity = await GetTaskAsync("t1");
Assert.Equal("target", entity.ListId);
}
[Fact]
public async Task Move_BothWithoutWorkingDir_MovesWithoutConfirmDialog()
{
await SeedListsAsync();
await SeedTaskAsync("t1", TaskStatus.Idle);
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, SourceList());
var dialogs = new FakeDialogService();
vm.Dialogs = dialogs;
var row = vm.Items.First(r => r.Id == "t1");
await vm.MoveTaskToListAsync(row, TargetList());
Assert.Equal(0, dialogs.ConfirmCalls);
var entity = await GetTaskAsync("t1");
Assert.Equal("target", entity.ListId);
}
[Fact]
public async Task Move_DifferentWorkingDir_AsksConfirmation_MovesOnConfirm()
{
await SeedListsAsync("C:/repoA", "C:/repoB");
await SeedTaskAsync("t1", TaskStatus.Idle);
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, SourceList("C:/repoA"));
var dialogs = new FakeDialogService { ConfirmResult = true };
vm.Dialogs = dialogs;
var row = vm.Items.First(r => r.Id == "t1");
await vm.MoveTaskToListAsync(row, TargetList("C:/repoB"));
Assert.Equal(1, dialogs.ConfirmCalls);
var entity = await GetTaskAsync("t1");
Assert.Equal("target", entity.ListId);
}
[Fact]
public async Task Move_DifferentWorkingDir_CancelledConfirmation_DoesNotMove()
{
await SeedListsAsync("C:/repoA", "C:/repoB");
await SeedTaskAsync("t1", TaskStatus.Idle);
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, SourceList("C:/repoA"));
var dialogs = new FakeDialogService { ConfirmResult = false };
vm.Dialogs = dialogs;
var row = vm.Items.First(r => r.Id == "t1");
await vm.MoveTaskToListAsync(row, TargetList("C:/repoB"));
Assert.Equal(1, dialogs.ConfirmCalls);
var entity = await GetTaskAsync("t1");
Assert.Equal("source", entity.ListId);
}
[Fact]
public async Task Move_TargetIsCurrentList_IsNoOp()
{
await SeedListsAsync();
await SeedTaskAsync("t1", TaskStatus.Idle);
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, SourceList());
var dialogs = new FakeDialogService();
vm.Dialogs = dialogs;
var row = vm.Items.First(r => r.Id == "t1");
await vm.MoveTaskToListAsync(row, SourceList());
Assert.Equal(0, dialogs.ConfirmCalls);
var entity = await GetTaskAsync("t1");
Assert.Equal("source", entity.ListId);
}
[Fact]
public async Task Move_DifferentWorkingDir_ConfirmMessageNamesBothRepos()
{
await SeedListsAsync("C:/repoA", "C:/repoB");
await SeedTaskAsync("t1", TaskStatus.Idle);
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, SourceList("C:/repoA"));
var dialogs = new FakeDialogService { ConfirmResult = false };
vm.Dialogs = dialogs;
var row = vm.Items.First(r => r.Id == "t1");
await vm.MoveTaskToListAsync(row, TargetList("C:/repoB"));
Assert.NotNull(dialogs.LastMessage);
Assert.Contains("C:/repoA", dialogs.LastMessage);
Assert.Contains("C:/repoB", dialogs.LastMessage);
}
// The source repo comes from the task's own list, not from the island's current list — those
// differ whenever a smart/virtual list is shown, and a wrong source dir would ask (or skip
// asking) for confirmation at the wrong times.
[Fact]
public async Task Move_SourceRepoIsReadFromTaskOwnList_NotCurrentList()
{
await SeedListsAsync("C:/repoA", "C:/repoA");
await SeedTaskAsync("t1", TaskStatus.Idle);
var vm = BuildViewModel();
// Island shows a virtual list carrying no working dir of its own.
await LoadAndWaitAsync(vm, SourceList(workingDir: null));
var dialogs = new FakeDialogService { ConfirmResult = true };
vm.Dialogs = dialogs;
var row = vm.Items.First(r => r.Id == "t1");
await vm.MoveTaskToListAsync(row, TargetList("C:/repoA"));
// Both lists really are the same repo, so no confirmation should have been needed.
Assert.Equal(0, dialogs.ConfirmCalls);
var entity = await GetTaskAsync("t1");
Assert.Equal("target", entity.ListId);
}
[Fact]
public async Task Move_TargetIsSmartList_IsNoOp()
{
await SeedListsAsync();
await SeedTaskAsync("t1", TaskStatus.Idle);
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, SourceList());
var row = vm.Items.First(r => r.Id == "t1");
var smart = new ListNavItemViewModel { Id = "smart:my-day", Kind = ListKind.Smart, Name = "My Day" };
await vm.MoveTaskToListAsync(row, smart);
var entity = await GetTaskAsync("t1");
Assert.Equal("source", entity.ListId);
}
}
@@ -0,0 +1,65 @@
using System.IO;
using ClaudeDo.Localization;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class WorktreesOverviewModalErrorTests
{
public WorktreesOverviewModalErrorTests()
{
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");
}
private sealed class ThrowingWorker : StubWorkerClient
{
public string ExceptionMessage { get; init; } = "worktree is locked by another process";
public override Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null) =>
throw new Exception(ExceptionMessage);
public override Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId) =>
throw new Exception(ExceptionMessage);
}
private static WorktreesOverviewModalViewModel NewVm(ThrowingWorker worker) =>
new(worker, () => null!, new MergeCoordinator());
[Fact]
public async Task CleanupFinished_WhenWorkerThrows_ShowsExceptionMessage()
{
var worker = new ThrowingWorker();
var vm = NewVm(worker);
await vm.CleanupFinishedCommand.ExecuteAsync(null);
Assert.NotNull(vm.StatusMessage);
Assert.Contains(worker.ExceptionMessage, vm.StatusMessage);
Assert.False(vm.IsBusy);
}
[Fact]
public async Task ForceRemove_WhenWorkerThrows_ShowsExceptionMessage_AndKeepsRow()
{
var worker = new ThrowingWorker();
var vm = NewVm(worker);
var row = new WorktreeOverviewRowViewModel
{
TaskId = "task-1",
TaskTitle = "Task 1",
TaskStatus = ClaudeDo.Data.Models.TaskStatus.Idle,
State = ClaudeDo.Data.Models.WorktreeState.Active,
};
vm.AddRowForTest(row);
await vm.ForceRemoveCommand.ExecuteAsync(row);
Assert.NotNull(vm.StatusMessage);
Assert.Contains(worker.ExceptionMessage, vm.StatusMessage);
Assert.Contains(row, vm.Rows);
}
}
@@ -0,0 +1,53 @@
using System.IO;
using ClaudeDo.Localization;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class WorktreesSettingsTabViewModelTests
{
public WorktreesSettingsTabViewModelTests()
{
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");
}
private sealed class ThrowingWorker : StubWorkerClient
{
public string ExceptionMessage { get; init; } = "disk full";
public override Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null) =>
throw new Exception(ExceptionMessage);
public override Task<WorktreeResetDto?> ResetAllWorktreesAsync() =>
throw new Exception(ExceptionMessage);
}
[Fact]
public async Task CleanupWorktrees_WhenWorkerThrows_ShowsExceptionMessage()
{
var worker = new ThrowingWorker();
var vm = new WorktreesSettingsTabViewModel(worker);
await vm.CleanupWorktreesCommand.ExecuteAsync(null);
Assert.Contains(worker.ExceptionMessage, vm.StatusMessage);
Assert.False(vm.IsBusy);
}
[Fact]
public async Task ConfirmResetAll_WhenWorkerThrows_ShowsExceptionMessage()
{
var worker = new ThrowingWorker();
var vm = new WorktreesSettingsTabViewModel(worker);
await vm.ConfirmResetAllCommand.ExecuteAsync(null);
Assert.Contains(worker.ExceptionMessage, vm.StatusMessage);
Assert.False(vm.IsBusy);
Assert.False(vm.ShowResetConfirm);
}
}
@@ -0,0 +1,31 @@
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Worker.Tests.External;
public sealed class AppSettingsMcpToolsTests : IDisposable
{
private readonly DbFixture _db = new();
public void Dispose() => _db.Dispose();
[Fact]
public async Task GetAppSettings_ReturnsMaxParallelExecutionsFromDb()
{
using (var ctx = _db.CreateContext())
{
var row = await ctx.AppSettings.FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId)
?? throw new InvalidOperationException("Expected seeded app_settings row.");
row.MaxParallelExecutions = 3;
await ctx.SaveChangesAsync();
}
var sut = new AppSettingsMcpTools(_db.CreateFactory());
var result = await sut.GetAppSettings(CancellationToken.None);
Assert.Equal(3, result.MaxParallelExecutions);
}
}
@@ -98,7 +98,7 @@ public class BroadcastLogSinkTests
[Fact] [Fact]
public void Does_not_throw_when_detached() public void Does_not_throw_when_detached()
{ {
var buffer = new LogRingBuffer(TimeSpan.FromHours(1)); var buffer = new LogRingBuffer(TimeSpan.FromHours(1), utcNow: () => EvtTime.UtcDateTime);
var sink = new BroadcastLogSink(buffer); var sink = new BroadcastLogSink(buffer);
sink.Emit(Evt(LogEventLevel.Error, "no subscriber")); sink.Emit(Evt(LogEventLevel.Error, "no subscriber"));
@@ -428,7 +428,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var pmIdx = args.IndexOf("--permission-mode"); var pmIdx = args.IndexOf("--permission-mode");
Assert.True(pmIdx >= 0); Assert.True(pmIdx >= 0);
Assert.Equal("default", args[pmIdx + 1]); Assert.Equal("auto", args[pmIdx + 1]);
var atIdx = args.IndexOf("--allowedTools"); var atIdx = args.IndexOf("--allowedTools");
Assert.Equal("mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill", args[atIdx + 1]); Assert.Equal("mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill", args[atIdx + 1]);