From 3fab4016f7d4897a765ee86625a2fc3cb6767ec3 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 2 Oct 2026 10:31:51 +0200 Subject: [PATCH] feat(tasks): offer Mark as Idle for cancelled tasks Co-Authored-By: Claude Sonnet 5.5 --- src/ClaudeDo.Localization/locales/de.json | 1 + src/ClaudeDo.Localization/locales/en.json | 1 + .../Views/Islands/TaskRowView.axaml.cs | 1 + src/ClaudeDo.Worker/Hub/WorkerHub.cs | 10 ++++-- .../Hub/SetTaskStatusHubTests.cs | 32 +++++++++++++++++++ 5 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 488333d4..edd631df 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -209,6 +209,7 @@ "ctxMarkAs": "Markieren als", "ctxMarkDone": "Erledigt", "ctxMarkCancelled": "Abgebrochen", + "ctxMarkIdle": "Offen", "ctxOpenConPtySession": "Interaktive Sitzung öffnen", "ctxOpenWorktree": "Worktree öffnen", "ctxOpenPlanningSession": "Planungssitzung öffnen", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 09b30069..c31efbdf 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -209,6 +209,7 @@ "ctxMarkAs": "Mark as", "ctxMarkDone": "Done", "ctxMarkCancelled": "Cancelled", + "ctxMarkIdle": "Idle", "ctxOpenConPtySession": "Open interactive session", "ctxOpenWorktree": "Open worktree", "ctxOpenPlanningSession": "Open planning Session", diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs index 78b5f5e3..d3f756ad 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs @@ -70,6 +70,7 @@ public partial class TaskRowView : UserControl var markAs = new MenuItem { Header = Loc.T("tasks.ctxMarkAs"), DataContext = row }; markAs.Items.Add(MakeItem("tasks.ctxMarkDone", OnSetStatusClick, tag: "Done", reason: row.MarkAsDisabledReason)); markAs.Items.Add(MakeItem("tasks.ctxMarkCancelled", OnSetStatusClick, tag: "Cancelled", reason: row.MarkAsDisabledReason)); + markAs.Items.Add(MakeItem("tasks.ctxMarkIdle", OnSetStatusClick, row.Status == TaskStatus.Cancelled, tag: "Idle", reason: row.MarkAsDisabledReason)); markAs.Items.Add(new Separator()); markAs.Items.Add(MakeItem("tasks.ctxMarkManual", OnToggleManualClick, !row.IsManual)); markAs.Items.Add(MakeItem("tasks.ctxMarkClaudeTask", OnToggleManualClick, row.IsManual)); diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 1c704b0a..2b9eb9b9 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -677,10 +677,14 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub throw new HubException("Cannot change the status of a running task. Cancel it first."); // Queueing goes through the gated transition so draft subtasks can't be queued; + // Idle goes through ResetToIdleAsync so StartedAt/FinishedAt/Result are cleared; // other statuses keep the unconditional "set status freely" affordance. - var result = parsed == TaskStatus.Queued - ? await _state.EnqueueAsync(taskId, Context.ConnectionAborted) - : await _state.ForceSetStatusAsync(taskId, parsed, Context.ConnectionAborted); + var result = parsed switch + { + TaskStatus.Queued => await _state.EnqueueAsync(taskId, Context.ConnectionAborted), + TaskStatus.Idle => await _state.ResetToIdleAsync(taskId, Context.ConnectionAborted), + _ => await _state.ForceSetStatusAsync(taskId, parsed, Context.ConnectionAborted), + }; if (!result.Ok) throw new HubException(result.Reason ?? "set status failed"); return new SetTaskStatusResultDto( result.BaseDirty is { } w ? new BaseDirtyWarningDto(w.ModifiedCount, w.UntrackedCount) : null); diff --git a/tests/ClaudeDo.Worker.Tests/Hub/SetTaskStatusHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/SetTaskStatusHubTests.cs index 382c8b88..e194bec6 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/SetTaskStatusHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/SetTaskStatusHubTests.cs @@ -76,6 +76,38 @@ public sealed class SetTaskStatusHubTests : IDisposable Assert.Equal(TaskStatus.Cancelled, task!.Status); } + [Fact] + public async Task SetTaskStatus_Idle_OnCancelledTask_ClearsTimestampsAndKeepsWorktree() + { + var taskId = await SeedTaskAsync(TaskStatus.Cancelled); + await using (var seed = _db.CreateContext()) + { + var t = (await seed.Tasks.FindAsync(taskId))!; + t.StartedAt = DateTime.UtcNow.AddMinutes(-5); + t.FinishedAt = DateTime.UtcNow; + t.Result = "stale"; + await seed.SaveChangesAsync(); + await new WorktreeRepository(seed).AddAsync(new WorktreeEntity + { + TaskId = taskId, Path = "/tmp/x", BranchName = "claudedo/x", + BaseCommit = "deadbeef", State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, + }); + } + var hub = CreateHub(); + + await hub.SetTaskStatus(taskId, "Idle"); + + await using var ctx = _db.CreateContext(); + var task = await ctx.Tasks.AsNoTracking().SingleAsync(t => t.Id == taskId); + Assert.Equal(TaskStatus.Idle, task.Status); + Assert.Null(task.StartedAt); + Assert.Null(task.FinishedAt); + Assert.Null(task.Result); + var wt = await ctx.Worktrees.AsNoTracking().SingleAsync(w => w.TaskId == taskId); + Assert.Equal(WorktreeState.Active, wt.State); + Assert.Equal("claudedo/x", wt.BranchName); + } + [Fact] public async Task SetTaskStatus_Missing_Throws() {