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
@@ -26,6 +26,7 @@ public abstract class StubWorkerClient : IWorkerClient
public event Action<string, string, string>? TaskQuestionAskedEvent;
public event Action<string, string>? TaskQuestionResolvedEvent;
public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
public event Action? NotesUpdatedEvent;
public event Action? PrepStartedEvent;
public event Action<string>? PrepLineEvent;
public event Action<bool>? PrepFinishedEvent;
@@ -163,6 +164,7 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task<(bool Ok, string? Error)> SetWorktreeStateAsync(string taskId, WorktreeState newState) => Task.FromResult((true, (string?)null));
public virtual Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId) => Task.FromResult<ForceRemoveResultDto?>(null);
public virtual Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>());
public virtual Task<List<DailyNoteDto>> GetDailyNotesBetweenAsync(DateOnly start, DateOnly end) => Task.FromResult(new List<DailyNoteDto>());
public virtual Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text) => Task.FromResult<DailyNoteDto?>(null);
public virtual Task UpdateDailyNoteAsync(string id, string text) => Task.CompletedTask;
public virtual Task DeleteDailyNoteAsync(string id) => Task.CompletedTask;
+1
View File
@@ -23,6 +23,7 @@ public sealed class NullServiceProvider : IServiceProvider
public sealed class StubNotesApi : INotesApi
{
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>());
public Task<List<DailyNoteDto>> ListBetweenAsync(DateOnly start, DateOnly end) => Task.FromResult(new List<DailyNoteDto>());
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => Task.FromResult<DailyNoteDto?>(null);
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
public Task DeleteAsync(string id) => Task.CompletedTask;
@@ -48,7 +48,7 @@ public class DetailsIslandConflictSeamTests : IDisposable
private DetailsIslandViewModel BuildVm(StubWorkerClient worker, MergeCoordinator merge)
{
var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new StubNotesApi(), merge);
return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), merge);
}
[Fact]
@@ -56,7 +56,7 @@ public class DetailsIslandDeleteTaskTests : IDisposable
private DetailsIslandViewModel BuildVm(RecordingWorkerClient worker)
{
var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator());
return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
}
[Fact]
@@ -59,7 +59,7 @@ public class DetailsIslandDescriptionSaveRaceTests : IDisposable
}
private DetailsIslandViewModel BuildVm() =>
new(new TestDbFactory(NewContext), new FakeWorker(), new NullServiceProvider(), new StubNotesApi(),
new(new TestDbFactory(NewContext), new FakeWorker(), new NullServiceProvider(),
new ClaudeDo.Ui.Services.MergeCoordinator());
private async Task<TaskEntity> ReadTaskAsync(string id)
@@ -38,7 +38,7 @@ public class DetailsIslandEmptyStateTests : IDisposable
private DetailsIslandViewModel NewDetailsVm(StubWorkerClient stub)
{
var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, stub, new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator());
return new DetailsIslandViewModel(factory, stub, new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
}
[Fact]
@@ -60,17 +60,6 @@ public class DetailsIslandEmptyStateTests : IDisposable
Assert.False(vm.IsEmptyStateVisible);
}
[Fact]
public void IsEmptyStateVisible_false_in_notes_mode_even_without_a_task()
{
var vm = NewDetailsVm(new DefaultStub());
vm.ShowNotes();
Assert.True(vm.IsNotesMode);
Assert.Null(vm.Task);
Assert.False(vm.IsEmptyStateVisible);
}
[Fact]
public void IsEmptyStateVisible_false_in_prep_mode_even_without_a_task()
@@ -86,7 +86,7 @@ public class DetailsIslandErrorFeedbackTests : IDisposable
{
var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(
factory, worker, new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator());
factory, worker, new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
}
[Fact]
@@ -48,7 +48,7 @@ public class DetailsIslandHandlerRangeTests : IDisposable
{
var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(
factory, new FakeWorkerClient(), new NullServiceProvider(), new StubNotesApi(), new MergeCoordinator());
factory, new FakeWorkerClient(), new NullServiceProvider(), new MergeCoordinator());
}
[Fact]
@@ -47,7 +47,7 @@ public class DetailsIslandPlanningTests : IDisposable
private DetailsIslandViewModel BuildVm(StubWorkerClient worker)
{
var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator());
return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
}
// Connected worker whose review calls fail the way the hub does when the task
@@ -37,7 +37,7 @@ public class DetailsIslandPrepModeTests : IDisposable
private DetailsIslandViewModel NewDetailsVm(StubWorkerClient stub)
{
var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, stub, new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator());
return new DetailsIslandViewModel(factory, stub, new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
}
@@ -53,13 +53,12 @@ public class DetailsIslandPrepModeTests : IDisposable
}
[Fact]
public void ShowPrep_sets_prep_mode_and_clears_notes_mode()
public void ShowPrep_sets_prep_mode_and_hides_the_task_detail()
{
var vm = NewDetailsVm(new DefaultStub());
vm.ShowPrep();
Assert.True(vm.IsPrepMode);
Assert.False(vm.IsNotesMode);
Assert.False(vm.IsTaskDetailVisible);
}
@@ -48,7 +48,7 @@ public class DetailsIslandResetAndRetryTests : IDisposable
{
var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(
factory, worker, new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator());
factory, worker, new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
}
[Fact]
@@ -50,7 +50,7 @@ public class DetailsIslandReviewActionsTests : IDisposable
private DetailsIslandViewModel BuildVm(StubWorkerClient worker)
{
var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator());
return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
}
[Fact]
@@ -63,7 +63,7 @@ public class DetailsIslandRoadblockReplyTests : IDisposable
private DetailsIslandViewModel BuildVm(StubWorkerClient worker)
{
var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator());
return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
}
private static DetailsIslandViewModel PrepareDoneTaskWithRoadblock(DetailsIslandViewModel vm, string taskId = "task-rb-1")
@@ -38,7 +38,7 @@ public class DetailsIslandTabsTests : IDisposable
private DetailsIslandViewModel NewVm()
{
var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, new DefaultStub(), new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator());
return new DetailsIslandViewModel(factory, new DefaultStub(), new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
}
[Fact]
@@ -40,7 +40,7 @@ public class DetailsIslandTaskIdBadgeTests : IDisposable
private DetailsIslandViewModel NewVm()
{
var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, new DefaultStub(), new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator());
return new DetailsIslandViewModel(factory, new DefaultStub(), new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
}
[Fact]
@@ -57,7 +57,7 @@ public class DetailsIslandTaskUpdatedTests : IDisposable
}
private DetailsIslandViewModel BuildVm(StubWorkerClient worker) =>
new(new TestDbFactory(NewContext), worker, new NullServiceProvider(), new StubNotesApi(),
new(new TestDbFactory(NewContext), worker, new NullServiceProvider(),
new ClaudeDo.Ui.Services.MergeCoordinator());
[Fact]
@@ -6,42 +6,110 @@ namespace ClaudeDo.Ui.Tests.ViewModels;
public class NotesEditorViewModelTests
{
private static readonly DateOnly Today = DateOnly.FromDateTime(DateTime.Today);
private sealed class FakeNotes : INotesApi
{
public readonly List<DailyNoteDto> Store = new();
private int _seq;
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) =>
Task.FromResult(Store.Where(n => n.Date == day.ToString("yyyy-MM-dd")).ToList());
Task.FromResult(Store.Where(n => n.Date == Iso(day)).ToList());
public Task<List<DailyNoteDto>> ListBetweenAsync(DateOnly start, DateOnly end) =>
Task.FromResult(Store
.Where(n => DateOnly.Parse(n.Date) >= start && DateOnly.Parse(n.Date) <= end)
.ToList());
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text)
{
var dto = new DailyNoteDto($"id{_seq++}", day.ToString("yyyy-MM-dd"), text, Store.Count);
var dto = new DailyNoteDto($"id{_seq++}", Iso(day), text, Store.Count, false);
Store.Add(dto);
return Task.FromResult<DailyNoteDto?>(dto);
}
public Task UpdateAsync(string id, string text)
{
var i = Store.FindIndex(n => n.Id == id);
if (i >= 0) Store[i] = Store[i] with { Text = text };
return Task.CompletedTask;
}
public Task DeleteAsync(string id) { Store.RemoveAll(n => n.Id == id); return Task.CompletedTask; }
public void Seed(DateOnly day, string text, bool fromAgent = false) =>
Store.Add(new DailyNoteDto($"id{_seq++}", Iso(day), text, Store.Count, fromAgent));
private static string Iso(DateOnly d) => d.ToString("yyyy-MM-dd");
}
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<List<DailyNoteDto>> ListBetweenAsync(DateOnly start, DateOnly end) => 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;
}
private static async Task<(NotesEditorViewModel vm, FakeNotes api)> LoadedAsync(Action<FakeNotes>? seed = null)
{
var api = new FakeNotes();
seed?.Invoke(api);
var vm = new NotesEditorViewModel(api);
await vm.LoadAsync();
return (vm, api);
}
private static List<NoteBulletViewModel> Bullets(NotesEditorViewModel vm) =>
vm.Rows.OfType<NoteBulletViewModel>().ToList();
[Fact]
public async Task LoadAsync_groups_notes_by_day_with_today_first()
{
var (vm, _) = await LoadedAsync(api =>
{
api.Seed(Today.AddDays(-2), "vorgestern");
api.Seed(Today, "heute");
});
var headers = vm.Rows.OfType<NoteDayHeaderViewModel>().ToList();
Assert.Equal(Today, headers[0].Day);
Assert.True(headers[0].IsToday);
Assert.Equal(Today.AddDays(-2), headers[1].Day);
Assert.Equal("heute", ((NoteBulletViewModel)vm.Rows[1]).Text);
}
[Fact]
public async Task LoadAsync_keeps_todays_header_when_there_is_nothing_yet()
{
var (vm, _) = await LoadedAsync();
Assert.Single(vm.Rows);
Assert.True(vm.IsEmpty);
}
[Fact]
public async Task AddBullet_files_the_note_under_today_and_clears_the_box()
{
var (vm, api) = await LoadedAsync(api => api.Seed(Today.AddDays(-1), "gestern"));
vm.NewBulletText = "Standup vorbereitet";
await vm.AddBulletCommand.ExecuteAsync(null);
Assert.Equal("Standup vorbereitet", ((NoteBulletViewModel)vm.Rows[1]).Text);
Assert.Equal("", vm.NewBulletText);
Assert.False(vm.IsEmpty);
Assert.Equal(2, api.Store.Count);
}
[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));
await vm.LoadAsync();
string? reportedError = null;
vm.ErrorReported += msg => reportedError = msg;
@@ -50,73 +118,123 @@ public class NotesEditorViewModelTests
await vm.AddBulletCommand.ExecuteAsync(null);
Assert.Equal(api.ExceptionMessage, reportedError);
Assert.Empty(vm.Bullets);
Assert.Empty(Bullets(vm));
Assert.Equal("Standup vorbereitet", vm.NewBulletText);
}
[Fact]
public async Task AddBullet_PersistsAndAppears_ForCurrentDay()
public async Task SplitBullet_saves_the_edit_then_opens_an_empty_row_below_and_focuses_it()
{
var api = new FakeNotes();
var vm = new NotesEditorViewModel(api);
await vm.LoadDayAsync(new DateOnly(2026, 6, 1));
var (vm, api) = await LoadedAsync(api => api.Seed(Today, "erste"));
NoteBulletViewModel? focused = null;
vm.FocusRequested += b => focused = b;
vm.NewBulletText = "Standup vorbereitet";
await vm.AddBulletCommand.ExecuteAsync(null);
var first = Bullets(vm)[0];
first.Text = "erste, bearbeitet";
await vm.SplitBulletCommand.ExecuteAsync(first);
Assert.Single(vm.Bullets);
Assert.Equal("Standup vorbereitet", vm.Bullets[0].Text);
Assert.Equal("", vm.NewBulletText);
var rows = Bullets(vm);
Assert.Equal(2, rows.Count);
Assert.Same(rows[1], focused);
Assert.True(rows[1].IsNew);
Assert.Equal("erste, bearbeitet", api.Store[0].Text);
// The empty row is UI-only until it gets text, so Enter on a blank line leaves nothing behind.
Assert.Single(api.Store);
}
[Fact]
public async Task PrevAndNextDay_NavigateAndReload()
public async Task SplitBullet_on_an_emptied_note_deletes_it_and_adds_no_row()
{
var api = new FakeNotes();
await api.AddAsync(new DateOnly(2026, 5, 31), "gestern");
var vm = new NotesEditorViewModel(api);
await vm.LoadDayAsync(new DateOnly(2026, 6, 1));
Assert.Empty(vm.Bullets);
var (vm, api) = await LoadedAsync(api => api.Seed(Today, "weg damit"));
await vm.PrevDayCommand.ExecuteAsync(null);
Assert.Equal(new DateOnly(2026, 5, 31), vm.CurrentDay);
Assert.Single(vm.Bullets);
var only = Bullets(vm)[0];
only.Text = " ";
await vm.SplitBulletCommand.ExecuteAsync(only);
await vm.NextDayCommand.ExecuteAsync(null);
Assert.Equal(new DateOnly(2026, 6, 1), vm.CurrentDay);
Assert.Empty(vm.Bullets);
Assert.Empty(Bullets(vm));
Assert.Empty(api.Store);
Assert.True(vm.IsEmpty);
}
[Fact]
public async Task CommitBullet_persists_a_new_row_and_gives_it_an_id()
{
var (vm, api) = await LoadedAsync(api => api.Seed(Today, "erste"));
await vm.SplitBulletCommand.ExecuteAsync(Bullets(vm)[0]);
var fresh = Bullets(vm)[1];
fresh.Text = "zweite";
await vm.CommitBulletCommand.ExecuteAsync(fresh);
Assert.False(fresh.IsNew);
Assert.Equal(2, api.Store.Count);
Assert.Equal("zweite", api.Store[1].Text);
}
[Fact]
public async Task DeleteBullet_removes_it_and_focuses_the_note_above()
{
var (vm, api) = await LoadedAsync(api => { api.Seed(Today, "eins"); api.Seed(Today, "zwei"); });
NoteBulletViewModel? focused = null;
vm.FocusRequested += b => focused = b;
var second = Bullets(vm)[1];
await vm.DeleteBulletCommand.ExecuteAsync(second);
Assert.Single(Bullets(vm));
Assert.Equal("eins", focused?.Text);
Assert.Single(api.Store);
}
[Fact]
public async Task DeleteBullet_on_the_first_note_of_the_day_does_not_reach_into_the_header()
{
var (vm, _) = await LoadedAsync(api => api.Seed(Today, "einzige"));
var focusCalls = 0;
vm.FocusRequested += _ => focusCalls++;
await vm.DeleteBulletCommand.ExecuteAsync(Bullets(vm)[0]);
Assert.Equal(0, focusCalls);
Assert.True(vm.IsEmpty);
}
[Fact]
public async Task CommitBullet_EmptyText_RemovesFromStoreAndList()
{
var api = new FakeNotes();
var vm = new NotesEditorViewModel(api);
await vm.LoadDayAsync(new DateOnly(2026, 6, 1));
vm.NewBulletText = "weg damit";
await vm.AddBulletCommand.ExecuteAsync(null);
var (vm, api) = await LoadedAsync(api => api.Seed(Today, "weg damit"));
vm.Bullets[0].Text = " ";
await vm.CommitBulletCommand.ExecuteAsync(vm.Bullets[0]);
var only = Bullets(vm)[0];
only.Text = " ";
await vm.CommitBulletCommand.ExecuteAsync(only);
Assert.Empty(vm.Bullets);
Assert.Empty(Bullets(vm));
Assert.Empty(api.Store);
}
[Fact]
public async Task CommitBullet_NonEmptyText_PersistsEdit()
{
var api = new FakeNotes();
var vm = new NotesEditorViewModel(api);
await vm.LoadDayAsync(new DateOnly(2026, 6, 1));
vm.NewBulletText = "original";
await vm.AddBulletCommand.ExecuteAsync(null);
var (vm, api) = await LoadedAsync(api => api.Seed(Today, "original"));
vm.Bullets[0].Text = "geändert";
await vm.CommitBulletCommand.ExecuteAsync(vm.Bullets[0]);
var only = Bullets(vm)[0];
only.Text = "geaendert";
await vm.CommitBulletCommand.ExecuteAsync(only);
Assert.Single(vm.Bullets);
Assert.Equal("geändert", api.Store[0].Text);
Assert.Single(Bullets(vm));
Assert.Equal("geaendert", api.Store[0].Text);
}
[Fact]
public async Task Agent_written_notes_are_marked_so_the_view_can_flag_them()
{
var (vm, _) = await LoadedAsync(api =>
{
api.Seed(Today, "von mir");
api.Seed(Today, "von Claude", fromAgent: true);
});
Assert.False(Bullets(vm)[0].FromAgent);
Assert.True(Bullets(vm)[1].FromAgent);
}
}
@@ -112,6 +112,7 @@ sealed class FakeWorkerClient : IWorkerClient
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
public Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync() => Task.FromResult<IReadOnlyList<WorkerLogEntry>>(System.Array.Empty<WorkerLogEntry>());
public event Action? NotesUpdatedEvent;
public event Action? PrepStartedEvent;
public event Action<string>? PrepLineEvent;
public event Action<bool>? PrepFinishedEvent;
@@ -153,6 +154,7 @@ sealed class FakeWorkerClient : IWorkerClient
public Task<bool> RunDailyPrepNowAsync() => Task.FromResult(false);
public Task ClearMyDayAsync() => Task.CompletedTask;
public Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>());
public Task<List<DailyNoteDto>> GetDailyNotesBetweenAsync(DateOnly start, DateOnly end) => Task.FromResult(new List<DailyNoteDto>());
public Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text) => Task.FromResult<DailyNoteDto?>(null);
public Task UpdateDailyNoteAsync(string id, string text) => Task.CompletedTask;
public Task DeleteDailyNoteAsync(string id) => Task.CompletedTask;