feat(notes): notes get their own list, a day log and an Enter chain

The editor was reachable from one button that only appeared in My Day,
which is how it got forgotten. It is now a permanent sidebar entry next
to My Day and Planned, and it fills the tasks island rather than the
details pane, because a note list is content.

Inside, the date picker is gone: today sits at the top with the capture
box, older days follow as dated groups. Enter in a note saves it and
opens the next one, Shift+Enter breaks the line so notes can be several,
Backspace on an empty note deletes it and steps back up. Deleting also
has a visible hover button instead of only "clear the text and hope".
Agent-written notes carry a marker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
CubeGameLP
2026-08-28 21:19:37 +02:00
co-authored by Claude Opus 5
parent ae0c7d3771
commit 0f04b796f4
34 changed files with 533 additions and 192 deletions
@@ -20,7 +20,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IWorkerClient _worker;
private readonly IServiceProvider _services;
private readonly INotesApi _notesApi;
private readonly MergeCoordinator _merge;
// ── Section view models ───────────────────────────────────────────────────
@@ -39,29 +38,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private readonly Action<string, string?> _workerPlanningMergeAbortedHandler;
private readonly Action<string> _workerPlanningCompletedHandler;
[ObservableProperty] private bool _isNotesMode;
[ObservableProperty] private bool _isPrepMode;
public bool IsTaskDetailVisible => !IsNotesMode && !IsPrepMode;
public bool IsTaskDetailVisible => !IsPrepMode;
/// <summary>Centered placeholder shown when no task is selected — hidden in Notes/Prep mode,
/// <summary>Centered placeholder shown when no task is selected — hidden in Prep mode,
/// where <see cref="IsTaskDetailVisible"/> is already false.</summary>
public bool IsEmptyStateVisible => Task is null && IsTaskDetailVisible;
partial void OnIsNotesModeChanged(bool value)
{
OnPropertyChanged(nameof(IsTaskDetailVisible));
OnPropertyChanged(nameof(IsEmptyStateVisible));
}
partial void OnIsPrepModeChanged(bool value)
{
OnPropertyChanged(nameof(IsTaskDetailVisible));
OnPropertyChanged(nameof(IsEmptyStateVisible));
}
public NotesEditorViewModel Notes { get; private set; } = null!;
// Current task row (set by IslandsShellViewModel via Bind)
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(EnqueueCommand))]
@@ -324,13 +314,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
IDbContextFactory<ClaudeDoDbContext> dbFactory,
IWorkerClient worker,
IServiceProvider services,
INotesApi notesApi,
MergeCoordinator merge)
{
_dbFactory = dbFactory;
_worker = worker;
_services = services;
_notesApi = notesApi;
_merge = merge;
Monitor = new TaskMonitorViewModel(dbFactory, worker);
@@ -374,8 +362,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
if (e.PropertyName == nameof(OperationStatus.IsRunning)) ParkReviewCommand.NotifyCanExecuteChanged();
};
Notes = new NotesEditorViewModel(_notesApi);
Notes.ErrorReported += msg => { if (ShowErrorAsync is not null) _ = ShowErrorAsync(msg); };
Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged();
Subtasks.CollectionChanged += (_, _) => Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count);
Attachments.CollectionChanged += (_, _) => OnPropertyChanged(nameof(FilesBadge));
@@ -592,18 +578,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
catch { }
}
public void ShowNotes()
{
Bind(null, "notes");
IsPrepMode = false;
IsNotesMode = true;
_ = Notes.LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
}
public void ShowPrep()
{
Bind(null, "prep");
IsNotesMode = false;
IsPrepMode = true;
_ = Prep.LoadLastPrepLogIfEmptyAsync();
}
@@ -612,7 +589,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
/// logged with the bind timing so rebind churn can be attributed to a trigger.</param>
public void Bind(TaskRowViewModel? row, string source = "?")
{
IsNotesMode = false;
IsPrepMode = false;
_loadCts?.Cancel();
_loadCts?.Dispose();
@@ -22,6 +22,10 @@ public sealed record MergeHelperRequest(string ListId, IReadOnlyList<string> Tas
public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
{
/// <summary>The day-log list. A smart list with no tasks in it: selecting it makes the tasks
/// island show the notes editor instead of rows.</summary>
public const string NotesListId = "smart:notes";
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IServiceProvider? _services;
private readonly IWorkerClient? _worker;
@@ -255,6 +259,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
"smart:my-day" => "vm.lists.smartMyDay",
"smart:important" => "vm.lists.smartImportant",
"smart:planned" => "vm.lists.smartPlanned",
NotesListId => "vm.lists.smartNotes",
"virtual:queued" => "vm.lists.virtualQueue",
"virtual:running" => "vm.lists.virtualRunning",
"virtual:review" => "vm.lists.virtualReview",
@@ -279,6 +284,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
new ListNavItemViewModel { Id = "smart:my-day", Name = Loc.T("vm.lists.smartMyDay"), Kind = ListKind.Smart, IconKey = "Sun" },
new ListNavItemViewModel { Id = "smart:important", Name = Loc.T("vm.lists.smartImportant"), Kind = ListKind.Smart, IconKey = "Star" },
new ListNavItemViewModel { Id = "smart:planned", Name = Loc.T("vm.lists.smartPlanned"), Kind = ListKind.Smart, IconKey = "Calendar" },
new ListNavItemViewModel { Id = NotesListId, Name = Loc.T("vm.lists.smartNotes"), Kind = ListKind.Smart, IconKey = "Text" },
new ListNavItemViewModel { Id = "virtual:queued", Name = Loc.T("vm.lists.virtualQueue"), Kind = ListKind.Virtual, IconKey = "Inbox" },
new ListNavItemViewModel { Id = "virtual:running", Name = Loc.T("vm.lists.virtualRunning"), Kind = ListKind.Virtual, IconKey = "Activity" },
new ListNavItemViewModel { Id = "virtual:review", Name = Loc.T("vm.lists.virtualReview"), Kind = ListKind.Virtual, IconKey = "Eye" },
@@ -1,25 +1,57 @@
using System.Collections.ObjectModel;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.Services.Interfaces;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Islands;
/// <summary>Date separator between the note groups. Rows carry these inline (like the task list's
/// HeaderRow) so one ItemsControl renders today and the history without a nested control per day.</summary>
public sealed class NoteDayHeaderViewModel
{
public DateOnly Day { get; }
public string Label { get; }
public bool IsToday { get; }
public NoteDayHeaderViewModel(DateOnly day, DateOnly today)
{
Day = day;
IsToday = day == today;
Label = day == today ? Loc.T("notes.today")
: day == today.AddDays(-1) ? Loc.T("notes.yesterday")
: day.ToString("dddd, dd.MM.");
}
}
public sealed partial class NoteBulletViewModel : ViewModelBase
{
public string Id { get; }
/// <summary>Empty until the note is persisted — a row the user started but hasn't typed into
/// yet exists only in the list, so an abandoned Enter leaves nothing behind in the database.</summary>
public string Id { get; set; }
public DateOnly Day { get; }
public bool FromAgent { get; }
public bool IsNew => Id.Length == 0;
[ObservableProperty] private string _text;
public NoteBulletViewModel(string id, string text)
public NoteBulletViewModel(string id, DateOnly day, string text, bool fromAgent = false)
{
Id = id;
Day = day;
_text = text;
FromAgent = fromAgent;
}
}
public sealed partial class NotesEditorViewModel : ViewModelBase
{
/// <summary>How far the history scrolls back. Older notes stay in the database and in the week
/// report; they are simply not worth the round trip in a day log.</summary>
public const int HistoryDays = 14;
private readonly INotesApi _api;
public NotesEditorViewModel(INotesApi api) => _api = api;
@@ -27,32 +59,58 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
// 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();
/// <summary>Raised after a row is inserted so the view can move the caret into it.</summary>
public event Action<NoteBulletViewModel>? FocusRequested;
/// <summary>NoteDayHeaderViewModel | NoteBulletViewModel, newest day first.</summary>
public ObservableCollection<object> Rows { get; } = new();
[ObservableProperty] private DateOnly _currentDay = DateOnly.FromDateTime(DateTime.Today);
[ObservableProperty] private string _newBulletText = "";
[ObservableProperty] private bool _isEmpty = true;
public DateTime CurrentDate
public DateOnly Today { get; private set; } = DateOnly.FromDateTime(DateTime.Today);
/// <summary>Loads today plus <see cref="HistoryDays"/> days of history.</summary>
public async Task LoadAsync()
{
get => CurrentDay.ToDateTime(TimeOnly.MinValue);
set { var d = DateOnly.FromDateTime(value); if (d != CurrentDay) _ = LoadDayAsync(d); }
Today = DateOnly.FromDateTime(DateTime.Today);
List<DailyNoteDto> notes;
try
{
notes = await _api.ListBetweenAsync(Today.AddDays(-HistoryDays), Today);
}
catch (Exception ex)
{
ErrorReported?.Invoke(ex.Message);
return;
}
Rows.Clear();
// Today always gets a header, even with nothing under it — it is where typing lands.
Rows.Add(new NoteDayHeaderViewModel(Today, Today));
foreach (var n in notes.Where(n => Day(n) == Today).OrderBy(n => n.SortOrder))
Rows.Add(Bullet(n));
foreach (var group in notes.Where(n => Day(n) != Today)
.GroupBy(Day).OrderByDescending(g => g.Key))
{
Rows.Add(new NoteDayHeaderViewModel(group.Key, Today));
foreach (var n in group.OrderBy(n => n.SortOrder))
Rows.Add(Bullet(n));
}
RefreshEmpty();
}
public string CurrentDayLabel => CurrentDay.ToString("dddd, dd.MM.yyyy");
private static DateOnly Day(DailyNoteDto n) => DateOnly.Parse(n.Date);
public async Task LoadDayAsync(DateOnly day)
{
CurrentDay = day;
OnPropertyChanged(nameof(CurrentDate));
OnPropertyChanged(nameof(CurrentDayLabel));
Bullets.Clear();
foreach (var dto in await _api.ListAsync(day))
Bullets.Add(MakeBullet(dto.Id, dto.Text));
}
private static NoteBulletViewModel Bullet(DailyNoteDto n) =>
new(n.Id, Day(n), n.Text, n.FromAgent);
private NoteBulletViewModel MakeBullet(string id, string text) =>
new(id, text);
private void RefreshEmpty() => IsEmpty = !Rows.OfType<NoteBulletViewModel>().Any();
/// <summary>The box above the list: Enter files the note under today and keeps the box focused,
/// so a burst of notes is one uninterrupted stream of typing.</summary>
[RelayCommand]
private async Task AddBullet()
{
@@ -60,9 +118,10 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
if (text.Length == 0) return;
try
{
var dto = await _api.AddAsync(CurrentDay, text);
if (dto is not null) Bullets.Add(MakeBullet(dto.Id, dto.Text));
var dto = await _api.AddAsync(Today, text);
if (dto is not null) Rows.Insert(1, Bullet(dto));
NewBulletText = "";
RefreshEmpty();
}
catch (Exception ex)
{
@@ -70,21 +129,68 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
}
}
[RelayCommand] private Task PrevDay() => LoadDayAsync(CurrentDay.AddDays(-1));
[RelayCommand] private Task NextDay() => LoadDayAsync(CurrentDay.AddDays(1));
[RelayCommand] private Task Today() => LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
/// <summary>Enter inside a note: save it, then open an empty row underneath and focus it.</summary>
[RelayCommand]
private async Task SplitBullet(NoteBulletViewModel? b)
{
if (b is null) return;
await CommitBullet(b);
if (!Rows.Contains(b)) return; // committing an emptied note removed it
var fresh = new NoteBulletViewModel("", b.Day, "");
Rows.Insert(Rows.IndexOf(b) + 1, fresh);
FocusRequested?.Invoke(fresh);
}
/// <summary>Backspace in an already-empty note: drop it and put the caret in the one above.</summary>
[RelayCommand]
private async Task DeleteBullet(NoteBulletViewModel? b)
{
if (b is null) return;
var index = Rows.IndexOf(b);
await RemoveAsync(b);
for (var i = index - 1; i >= 0; i--)
if (Rows[i] is NoteBulletViewModel previous)
{
FocusRequested?.Invoke(previous);
return;
}
}
[RelayCommand]
private async Task CommitBullet(NoteBulletViewModel? b)
{
if (b is null) return;
var text = b.Text?.Trim() ?? "";
if (text.Length == 0)
try
{
await _api.DeleteAsync(b.Id);
Bullets.Remove(b);
return;
if (text.Length == 0)
{
await RemoveAsync(b);
return;
}
if (b.IsNew)
{
var dto = await _api.AddAsync(b.Day, text);
if (dto is not null) b.Id = dto.Id;
}
else
{
await _api.UpdateAsync(b.Id, text);
}
}
await _api.UpdateAsync(b.Id, text);
catch (Exception ex)
{
ErrorReported?.Invoke(ex.Message);
}
}
private async Task RemoveAsync(NoteBulletViewModel b)
{
if (!b.IsNew) await _api.DeleteAsync(b.Id);
Rows.Remove(b);
RefreshEmpty();
}
}
@@ -9,6 +9,7 @@ using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.Services.Interfaces;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -62,18 +63,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
public event EventHandler? FocusAddTaskRequested;
public event EventHandler? TasksChanged;
public event Action? NotesRequested;
public event Action? PrepRequested;
public event Action<string>? ErrorReported;
public void RequestFocusAddTask() => FocusAddTaskRequested?.Invoke(this, EventArgs.Empty);
[RelayCommand]
private void OpenNotes()
{
SelectFrom(null, "notes");
NotesRequested?.Invoke();
}
[RelayCommand]
private void ShowPrepLog() => PrepRequested?.Invoke();
@@ -118,8 +111,17 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
private bool _hasCompleted;
[ObservableProperty] private bool _showOpenLabel;
[ObservableProperty] private string _completedHeader = "";
[ObservableProperty] private bool _showNotesRow;
[ObservableProperty] private bool _isMyDayList;
/// <summary>The notes list is content, not a task list — the island swaps its rows for the
/// notes editor instead of showing an empty task list.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsTasksEmptyHintVisible))]
[NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))]
private bool _isNotesList;
/// <summary>Null only in tests that construct the island without a notes API.</summary>
public NotesEditorViewModel? Notes { get; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))]
private bool _isLetClaudeVisible;
@@ -128,7 +130,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
/// <summary>No visible tasks below the add-task row — every item lands in one of
/// Overdue/Open/Completed, so all-false here always means the list has zero items.</summary>
public bool IsTasksEmptyHintVisible => !HasOverdue && !HasOpen && !HasCompleted;
public bool IsTasksEmptyHintVisible => !HasOverdue && !HasOpen && !HasCompleted && !IsNotesList;
/// <summary>Extra empty-state line for a User list with no linked working dir, where the
/// execution features (queue, Let Claude handle it, Quick session) are unavailable. Derived
@@ -182,10 +184,20 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
private readonly EventHandler _langChangedHandler;
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient? worker = null)
public TasksIslandViewModel(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
IWorkerClient? worker = null,
INotesApi? notesApi = null)
{
_dbFactory = dbFactory;
_worker = worker;
if (notesApi is not null)
{
Notes = new NotesEditorViewModel(notesApi);
Notes.ErrorReported += msg => ErrorReported?.Invoke(msg);
if (worker is not null)
worker.NotesUpdatedEvent += () => { if (IsNotesList) _ = Notes.LoadAsync(); };
}
_completedHeaderRow = new() { ActionCommand = ClearCompletedCommand };
CompletedHeader = Loc.T("vm.tasksIsland.completedHeader");
if (_worker is not null)
@@ -417,14 +429,14 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
HasOpen = false;
HasCompleted = false;
ShowOpenLabel = false;
ShowNotesRow = false;
if (listChanged) SelectFrom(null, "list-change");
if (list is null) { IsLetClaudeVisible = false; IsQuickClaudeVisible = false; IsMergeReviewsVisible = false; LoadTask = Task.CompletedTask; return; }
HeaderTitle = list.Name;
HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant();
ShowNotesRow = list.Id == "smart:my-day";
IsMyDayList = list.Id == "smart:my-day";
IsNotesList = list.Id == ListsIslandViewModel.NotesListId;
if (IsNotesList && Notes is not null) _ = Notes.LoadAsync();
IsLetClaudeVisible = list.Kind == ListKind.User && !string.IsNullOrWhiteSpace(list.WorkingDir);
IsQuickClaudeVisible = IsLetClaudeVisible;
IsMergeReviewsVisible = list.Id == "virtual:review";