feat(tasks): mark tasks and lists as manual
Reminders written down as todos had no home: every task looked like Claude work. A manual task now shows a MANUAL badge and hides send-to-queue, refine and the planning session; the queue picker, daily prep and the list handler all skip it, with a TaskStateService guard so the MCP surface and hub cannot start one either. Opening a hand-driven ConPTY session stays available on purpose. A list can be marked manual in its settings, which makes tasks created there (UI and MCP add_task) start out manual. Toggle per task from its context menu.
This commit is contained in:
@@ -12,6 +12,8 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _isActive;
|
||||
[ObservableProperty] private string? _workingDir;
|
||||
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
||||
// Reminder list: tasks created here default to manual.
|
||||
[ObservableProperty] private bool _isManual;
|
||||
[ObservableProperty] private bool _dropHintAbove;
|
||||
[ObservableProperty] private bool _dropHintBelow;
|
||||
public string? IconKey { get; init; }
|
||||
|
||||
@@ -47,7 +47,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
if (row is null || Dialogs is null || _services is null) return;
|
||||
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
|
||||
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType);
|
||||
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual);
|
||||
await Dialogs.ShowListSettingsAsync(vm);
|
||||
if (vm.Deleted) await LoadAsync();
|
||||
else await RefreshRowAsync(row.Id);
|
||||
@@ -243,6 +243,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
DotColorKey = dotColors[idx % dotColors.Length],
|
||||
WorkingDir = l.WorkingDir,
|
||||
DefaultCommitType = l.DefaultCommitType,
|
||||
IsManual = l.IsManual,
|
||||
};
|
||||
Items.Add(item);
|
||||
UserLists.Add(item);
|
||||
@@ -317,7 +318,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
if (Dialogs is not null && _services is not null)
|
||||
{
|
||||
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType);
|
||||
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual);
|
||||
await Dialogs.ShowListSettingsAsync(vm);
|
||||
if (vm.Deleted) await LoadAsync();
|
||||
else await RefreshRowAsync(item.Id);
|
||||
@@ -397,6 +398,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
row.Name = entity.Name;
|
||||
row.WorkingDir = entity.WorkingDir;
|
||||
row.DefaultCommitType = entity.DefaultCommitType;
|
||||
row.IsManual = entity.IsManual;
|
||||
}
|
||||
catch { /* best-effort refresh */ }
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _parentInView = true;
|
||||
[ObservableProperty] private int _roadblockCount;
|
||||
[ObservableProperty] private bool _isRefining;
|
||||
// Manual = a reminder only the user can do. Every "hand this to Claude" affordance is hidden
|
||||
// and automation skips it; opening a hand-driven ConPTY session stays allowed.
|
||||
[ObservableProperty] private bool _isManual;
|
||||
// Set by the shell from Mission Control's open ConPTY panes: this task has a live hand-driven
|
||||
// session, which outranks the persisted status on the lifecycle chip (an interactive task is
|
||||
// typically Idle+Active-worktree, i.e. would otherwise read "Parked").
|
||||
@@ -42,7 +45,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
// Set by the custom drag while this row is being dragged — drives the "grabbed" row style.
|
||||
[ObservableProperty] private bool _isDragging;
|
||||
|
||||
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None && !IsRefining;
|
||||
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None
|
||||
&& !IsRefining && !IsManual;
|
||||
|
||||
public string? ManualBadge => IsManual ? Loc.T("tasks.badgeManual") : null;
|
||||
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public string CreatedAtFormatted => CreatedAt == default ? "—" : Loc.T("vm.taskRow.createdPrefix", CreatedAt.ToString("MMM d"));
|
||||
@@ -64,7 +70,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
|
||||
public bool CanOpenPlanningSession => Status == TaskStatus.Idle
|
||||
&& PlanningPhase == PlanningPhase.None
|
||||
&& !IsChild;
|
||||
&& !IsChild
|
||||
&& !IsManual;
|
||||
public bool CanResumeOrDiscardPlanning => PlanningPhase == PlanningPhase.Active;
|
||||
|
||||
// Pick up in a terminal only where a session + worktree reliably still exist: a task
|
||||
@@ -100,7 +107,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
// it must be finalized first.
|
||||
public bool CanSendToQueue => !IsRunning && !IsQueued && !IsWaitingForReview && !HasQueuedSubtasks
|
||||
&& (!IsChild || ParentFinalized)
|
||||
&& PlanningPhase != PlanningPhase.Active;
|
||||
&& PlanningPhase != PlanningPhase.Active
|
||||
&& !IsManual;
|
||||
// Parent-level "send plan to queue" — only once the plan is finalized (children Planned).
|
||||
// Drives the routing inside SendToQueue, not a separate menu entry.
|
||||
public bool CanQueuePlan => !IsChild && HasPlanningChildren
|
||||
@@ -225,6 +233,14 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
|
||||
partial void OnIsRefiningChanged(bool value) => OnPropertyChanged(nameof(CanRefine));
|
||||
|
||||
partial void OnIsManualChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ManualBadge));
|
||||
OnPropertyChanged(nameof(CanRefine));
|
||||
OnPropertyChanged(nameof(CanSendToQueue));
|
||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||
}
|
||||
|
||||
partial void OnHasInteractiveSessionChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsParked));
|
||||
@@ -298,6 +314,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
Done = t.Status == TaskStatus.Done;
|
||||
IsStarred = t.IsStarred;
|
||||
IsMyDay = t.IsMyDay;
|
||||
IsManual = t.IsManual;
|
||||
Status = t.Status;
|
||||
PlanningPhase = t.PlanningPhase;
|
||||
Branch = t.Worktree?.BranchName;
|
||||
|
||||
@@ -427,6 +427,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
if (string.IsNullOrWhiteSpace(NewTaskTitle) || _currentList?.Kind != ListKind.User) return;
|
||||
var listId = _currentList.Id["user:".Length..];
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
// A manual list holds reminders, so tasks created in it start out manual.
|
||||
var listIsManual = await db.Lists.Where(l => l.Id == listId).Select(l => l.IsManual).FirstOrDefaultAsync();
|
||||
var entity = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
@@ -434,6 +436,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
Title = NewTaskTitle.Trim(),
|
||||
Status = TaskStatus.Idle,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsManual = listIsManual,
|
||||
};
|
||||
await new TaskRepository(db).AddAsync(entity);
|
||||
var row = TaskRowViewModel.FromEntity(entity);
|
||||
@@ -622,6 +625,23 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// <summary>Flips a task between "Claude can run this" and "manual reminder". A manual task
|
||||
/// hides every hand-off affordance and is skipped by the queue picker and daily prep.</summary>
|
||||
[RelayCommand]
|
||||
private async Task ToggleManualAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
row.IsManual = !row.IsManual;
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||
if (entity != null)
|
||||
{
|
||||
entity.IsManual = row.IsManual;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddToMyDayAsync(TaskRowViewModel? row)
|
||||
{
|
||||
|
||||
@@ -28,6 +28,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
[ObservableProperty] private string _name = "";
|
||||
[ObservableProperty] private string _workingDir = "";
|
||||
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
||||
// A manual list holds reminders: tasks created here start out manual (TaskEntity.IsManual).
|
||||
[ObservableProperty] private bool _isManual;
|
||||
|
||||
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
|
||||
|
||||
@@ -49,10 +51,12 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
string name,
|
||||
string? workingDir,
|
||||
string defaultCommitType,
|
||||
bool isManual = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ListId = listId;
|
||||
Name = name;
|
||||
IsManual = isManual;
|
||||
WorkingDir = workingDir ?? "";
|
||||
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
||||
|
||||
@@ -66,7 +70,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
ListId,
|
||||
string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name,
|
||||
string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
|
||||
DefaultCommitType));
|
||||
DefaultCommitType,
|
||||
IsManual));
|
||||
|
||||
await Agent.SaveAsync();
|
||||
|
||||
|
||||
@@ -56,6 +56,8 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
||||
var candidates = await ctx.Tasks.AsNoTracking()
|
||||
.Where(t => t.Status != TaskStatus.Done && t.Status != TaskStatus.Cancelled)
|
||||
.Where(t => t.ListId == _listId)
|
||||
// Manual tasks are reminders the user owns — never offer them to the handler.
|
||||
.Where(t => !t.IsManual)
|
||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||
.Select(t => new { t.Id, t.Title, t.Status })
|
||||
.ToListAsync(ct);
|
||||
|
||||
Reference in New Issue
Block a user