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
+2 -2
View File
@@ -165,13 +165,13 @@ sealed class Program
sc.AddSingleton<TasksIslandViewModel>(sp =>
new TasksIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<IWorkerClient>()));
sp.GetRequiredService<IWorkerClient>(),
sp.GetRequiredService<INotesApi>()));
sc.AddSingleton<DetailsIslandViewModel>(sp =>
new DetailsIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<IWorkerClient>(),
sp,
sp.GetRequiredService<INotesApi>(),
sp.GetRequiredService<MergeCoordinator>()));
sc.AddSingleton<UsagePillViewModel>(sp =>
new UsagePillViewModel(sp.GetRequiredService<IWorkerClient>()));
+6 -4
View File
@@ -177,7 +177,6 @@
"listSettingsTip": "Listeneinstellungen",
"addPlaceholder": "Aufgabe hinzufügen…",
"enterKey": "ENTER",
"notesPinnedRow": "Notizen (Tagesnotizen)",
"clearDayTip": "Tag leeren",
"planMyDayTip": "Meinen Tag planen",
"overdue": "ÜBERFÄLLIG",
@@ -299,8 +298,11 @@
},
"notes": {
"today": "Heute",
"add": "Hinzufügen",
"newNotePlaceholder": "Neue Notiz…"
"yesterday": "Gestern",
"newNotePlaceholder": "Neue Notiz…",
"emptyHint": "Noch nichts notiert. Tippen, Enter, fertig.",
"deleteTip": "Notiz löschen",
"fromAgentTip": "Von Claude notiert"
},
"session": {
"chipLive": "LIVE",
@@ -763,7 +765,7 @@
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" },
"mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
"about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" },
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste", "findingsNotFound": "Für diese Liste gibt es noch keine Findings.", "findingsOpenFailed": "Findings-Ordner konnte nicht geöffnet werden: {0}", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" },
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "smartNotes": "Notizen", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste", "findingsNotFound": "Für diese Liste gibt es noch keine Findings.", "findingsOpenFailed": "Findings-Ordner konnte nicht geöffnet werden: {0}", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" },
"repoImport": { "loadFailed": "Gespeicherte Ordner konnten nicht geladen werden: {0}", "saveFailed": "Ordner konnten nicht gespeichert werden: {0}" }
},
"ops": {
+6 -4
View File
@@ -177,7 +177,6 @@
"listSettingsTip": "List settings",
"addPlaceholder": "Add a task…",
"enterKey": "ENTER",
"notesPinnedRow": "Notes (daily notes)",
"clearDayTip": "Clear day",
"planMyDayTip": "Plan My Day",
"overdue": "OVERDUE",
@@ -299,8 +298,11 @@
},
"notes": {
"today": "Today",
"add": "Add",
"newNotePlaceholder": "New note…"
"yesterday": "Yesterday",
"newNotePlaceholder": "New note…",
"emptyHint": "Nothing noted yet. Type, hit Enter, done.",
"deleteTip": "Delete note",
"fromAgentTip": "Written by Claude"
},
"session": {
"chipLive": "LIVE",
@@ -763,7 +765,7 @@
"detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" },
"mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" },
"about": { "openPathFailed": "Couldn't open path: {0}" },
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list", "findingsNotFound": "No findings yet for this list.", "findingsOpenFailed": "Couldn't open findings folder: {0}", "openInExplorerFailed": "Couldn't open path: {0}" },
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "smartNotes": "Notes", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list", "findingsNotFound": "No findings yet for this list.", "findingsOpenFailed": "Couldn't open findings folder: {0}", "openInExplorerFailed": "Couldn't open path: {0}" },
"repoImport": { "loadFailed": "Couldn't load remembered folders: {0}", "saveFailed": "Couldn't save folders: {0}" }
},
"ops": {
@@ -5,6 +5,8 @@ namespace ClaudeDo.Ui.Services.Interfaces;
public interface INotesApi
{
Task<List<DailyNoteDto>> ListAsync(DateOnly day);
/// <summary>Both ends inclusive — today plus its history in one call.</summary>
Task<List<DailyNoteDto>> ListBetweenAsync(DateOnly start, DateOnly end);
Task<DailyNoteDto?> AddAsync(DateOnly day, string text);
Task UpdateAsync(string id, string text);
Task DeleteAsync(string id);
@@ -30,6 +30,9 @@ public interface IWorkerClient : INotifyPropertyChanged
/// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final").</summary>
event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
/// <summary>An agent added a note through the MCP tool — an open notes editor reloads.</summary>
event Action? NotesUpdatedEvent;
event Action? PrepStartedEvent;
event Action<string>? PrepLineEvent;
event Action<bool>? PrepFinishedEvent;
@@ -172,6 +175,8 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<AppSettingsDto?> GetAppSettingsAsync();
Task UpdateAppSettingsAsync(AppSettingsDto dto);
Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day);
/// <summary>Both ends inclusive.</summary>
Task<List<DailyNoteDto>> GetDailyNotesBetweenAsync(DateOnly start, DateOnly end);
Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text);
Task UpdateDailyNoteAsync(string id, string text);
Task DeleteDailyNoteAsync(string id);
+5
View File
@@ -54,6 +54,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<string>? ListUpdatedEvent;
public event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
public event Action? NotesUpdatedEvent;
public event Action? PrepStartedEvent;
public event Action<string>? PrepLineEvent;
public event Action<bool>? PrepFinishedEvent;
@@ -213,6 +214,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
Dispatcher.UIThread.Post(() => PrimeFired?.Invoke(new PrimeFiredEvent(id, ok, msg, when)));
});
_hub.On("NotesUpdated", () => Dispatcher.UIThread.Post(() => NotesUpdatedEvent?.Invoke()));
_hub.On("PrepStarted", () => Dispatcher.UIThread.Post(() => PrepStartedEvent?.Invoke()));
_hub.On<string>("PrepLine", line => Dispatcher.UIThread.Post(() => PrepLineEvent?.Invoke(line)));
_hub.On<bool>("PrepFinished", ok => Dispatcher.UIThread.Post(() => PrepFinishedEvent?.Invoke(ok)));
@@ -475,6 +477,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day)
=> await TryInvokeAsync<List<DailyNoteDto>>("GetDailyNotes", IsoDay(day)) ?? new List<DailyNoteDto>();
public async Task<List<DailyNoteDto>> GetDailyNotesBetweenAsync(DateOnly start, DateOnly end)
=> await TryInvokeAsync<List<DailyNoteDto>>("GetDailyNotesBetween", IsoDay(start), IsoDay(end)) ?? new List<DailyNoteDto>();
public async Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text)
=> await InvokeTimedAsync<DailyNoteDto>("AddDailyNote", () => _hub.InvokeAsync<DailyNoteDto>("AddDailyNote", IsoDay(day), text));
@@ -7,6 +7,7 @@ public sealed class WorkerNotesApi : INotesApi
private readonly IWorkerClient _client;
public WorkerNotesApi(IWorkerClient client) => _client = client;
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => _client.GetDailyNotesAsync(day);
public Task<List<DailyNoteDto>> ListBetweenAsync(DateOnly start, DateOnly end) => _client.GetDailyNotesBetweenAsync(start, end);
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => _client.AddDailyNoteAsync(day, text);
public Task UpdateAsync(string id, string text) => _client.UpdateDailyNoteAsync(id, text);
public Task DeleteAsync(string id) => _client.DeleteDailyNoteAsync(id);
@@ -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";
@@ -340,7 +340,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
Lists.SelectionChanged += (_, _) => _ = RefreshStaleWorkerCheckAsync();
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask, Tasks.SelectionSource);
Tasks.NotesRequested += () => Details.ShowNotes();
Tasks.PrepRequested += () => Details.ShowPrep();
Tasks.ErrorReported += FlashFooterError;
Lists.ErrorReported += FlashFooterError;
@@ -125,17 +125,12 @@
DragCompleted="OnSplitterDragCompleted"/>
</Grid>
<!-- Empty state: no task selected, outside Notes/Prep mode -->
<!-- Empty state: no task selected, outside Prep mode -->
<TextBlock IsVisible="{Binding IsEmptyStateVisible}"
Text="{loc:Tr details.emptyState}"
Foreground="{DynamicResource TextMuteBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Notes mode -->
<Panel IsVisible="{Binding IsNotesMode}">
<islands:NotesEditorView DataContext="{Binding Notes}"/>
</Panel>
<!-- Daily-prep mode -->
<Panel IsVisible="{Binding IsPrepMode}">
<DockPanel>
@@ -1,38 +1,110 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Islands.NotesEditorView"
x:DataType="vm:NotesEditorViewModel">
<DockPanel Margin="16">
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8">
<Button Classes="btn" Content="&#x2039;" Command="{Binding PrevDayCommand}"/>
<ctl:ThemedDatePicker SelectedDate="{Binding CurrentDate, Mode=TwoWay}"/>
<Button Classes="btn" Content="&#x203A;" Command="{Binding NextDayCommand}"/>
<Button Classes="btn" Content="{loc:Tr notes.today}" Command="{Binding TodayCommand}"/>
<TextBlock Classes="meta" VerticalAlignment="Center" Text="{Binding CurrentDayLabel}"/>
</StackPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,12,0,8">
<Button DockPanel.Dock="Right" Classes="btn" Content="{loc:Tr notes.add}" Margin="8,0,0,0"
Command="{Binding AddBulletCommand}"/>
<TextBox PlaceholderText="{loc:Tr notes.newNotePlaceholder}" Text="{Binding NewBulletText}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AddBulletCommand}"/>
</TextBox.KeyBindings>
</TextBox>
</DockPanel>
<UserControl.Styles>
<!-- A note reads as text, not as a form field: the box only shows itself under the pointer
and while it has focus. -->
<Style Selector="TextBox.note">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="4,3"/>
<Setter Property="MinHeight" Value="0"/>
<Setter Property="AcceptsReturn" Value="True"/>
<Setter Property="TextWrapping" Value="Wrap"/>
</Style>
<Style Selector="TextBox.note /template/ Border#PART_BorderElement">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
</Style>
<ScrollViewer>
<ItemsControl ItemsSource="{Binding Bullets}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:NoteBulletViewModel">
<TextBox Text="{Binding Text}" Margin="0,2"
LostFocus="OnBulletLostFocus"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Style Selector="Border.note-row">
<Setter Property="CornerRadius" Value="6"/>
<Setter Property="Padding" Value="10,1,6,1"/>
<Setter Property="Margin" Value="4,1"/>
</Style>
<Style Selector="Border.note-row:pointerover">
<Setter Property="Background" Value="{DynamicResource PeatSoftBrush}"/>
</Style>
<!-- Row actions stay out of the way until the row is hovered. -->
<Style Selector="Border.note-row Button.note-del">
<Setter Property="Opacity" Value="0"/>
</Style>
<Style Selector="Border.note-row:pointerover Button.note-del">
<Setter Property="Opacity" Value="1"/>
</Style>
</UserControl.Styles>
<DockPanel LastChildFill="True">
<!-- Today's capture box: Enter files the note and leaves the caret here for the next one. -->
<Border DockPanel.Dock="Top" Classes="add-task" Margin="16,14,16,10">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="add-task-plus" VerticalAlignment="Center">
<PathIcon Width="12" Height="12" Data="{StaticResource Icon.Plus}"
Foreground="{DynamicResource TextFaintBrush}"/>
</Border>
<TextBox Grid.Column="1" x:Name="NewNoteBox" Classes="add-task-input"
PlaceholderText="{loc:Tr notes.newNotePlaceholder}"
Text="{Binding NewBulletText, Mode=TwoWay}"
VerticalAlignment="Center" Margin="12,0,0,0">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AddBulletCommand}"/>
</TextBox.KeyBindings>
</TextBox>
<Border Grid.Column="2" Classes="kbd kbd-enter" VerticalAlignment="Center"
IsVisible="{Binding #NewNoteBox.IsFocused}">
<TextBlock Text="{loc:Tr tasks.enterKey}"/>
</Border>
</Grid>
</Border>
<Panel>
<ScrollViewer>
<ItemsControl x:Name="RowsHost" ItemsSource="{Binding Rows}" Margin="10,0,10,12">
<ItemsControl.DataTemplates>
<DataTemplate DataType="vm:NoteDayHeaderViewModel">
<TextBlock Classes="eyebrow section-label" Margin="14,12,14,4" Text="{Binding Label}"/>
</DataTemplate>
<DataTemplate DataType="vm:NoteBulletViewModel">
<Border Classes="note-row">
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<Ellipse Grid.Column="0" Width="4" Height="4" Margin="0,0,10,0"
VerticalAlignment="Center"
Fill="{DynamicResource TextFaintBrush}"/>
<TextBox Grid.Column="1" Classes="note" Text="{Binding Text, Mode=TwoWay}"
LostFocus="OnBulletLostFocus" KeyDown="OnBulletKeyDown"/>
<PathIcon Grid.Column="2" Width="12" Height="12" Margin="6,0"
VerticalAlignment="Center"
IsVisible="{Binding FromAgent}"
ToolTip.Tip="{loc:Tr notes.fromAgentTip}"
Data="{StaticResource Icon.AgentSuggested}"
Foreground="{DynamicResource TextFaintBrush}"/>
<Button Grid.Column="3" Classes="icon-btn note-del"
VerticalAlignment="Center"
ToolTip.Tip="{loc:Tr notes.deleteTip}"
Click="OnDeleteBulletClick">
<PathIcon Classes="danger" Data="{StaticResource Icon.Trash}" Width="12" Height="12"/>
</Button>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.DataTemplates>
</ItemsControl>
</ScrollViewer>
<TextBlock IsVisible="{Binding IsEmpty}"
Text="{loc:Tr notes.emptyHint}"
Foreground="{DynamicResource TextMuteBrush}"
MaxWidth="260" TextAlignment="Center" TextWrapping="Wrap"
HorizontalAlignment="Center" VerticalAlignment="Center"
IsHitTestVisible="False"/>
</Panel>
</DockPanel>
</UserControl>
@@ -1,12 +1,53 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Threading;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Views.Islands;
public partial class NotesEditorView : UserControl
{
public NotesEditorView() => InitializeComponent();
private NotesEditorViewModel? _vm;
public NotesEditorView()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
if (_vm is not null) _vm.FocusRequested -= FocusBullet;
_vm = DataContext as NotesEditorViewModel;
if (_vm is not null) _vm.FocusRequested += FocusBullet;
}
/// <summary>Rows the VM just inserted have no container yet — wait for the layout pass that
/// creates it before reaching for the TextBox inside.</summary>
private void FocusBullet(NoteBulletViewModel bullet) =>
Dispatcher.UIThread.Post(
() => RowsHost.ContainerFromItem(bullet)?.FindDescendantOfType<TextBox>()?.Focus(),
DispatcherPriority.Loaded);
private void OnBulletKeyDown(object? sender, KeyEventArgs e)
{
if (sender is not TextBox { DataContext: NoteBulletViewModel bullet } box
|| DataContext is not NotesEditorViewModel vm) return;
// Shift+Enter falls through to the TextBox and breaks the line — a note can be several.
if (e.Key == Key.Enter && !e.KeyModifiers.HasFlag(KeyModifiers.Shift))
{
e.Handled = true;
vm.SplitBulletCommand.Execute(bullet);
}
else if (e.Key == Key.Back && string.IsNullOrEmpty(box.Text))
{
e.Handled = true;
vm.DeleteBulletCommand.Execute(bullet);
}
}
private void OnBulletLostFocus(object? sender, RoutedEventArgs e)
{
@@ -15,4 +56,11 @@ public partial class NotesEditorView : UserControl
&& vm.CommitBulletCommand.CanExecute(bullet))
vm.CommitBulletCommand.Execute(bullet);
}
private void OnDeleteBulletClick(object? sender, RoutedEventArgs e)
{
if (sender is Button { DataContext: NoteBulletViewModel bullet }
&& DataContext is NotesEditorViewModel vm)
vm.DeleteBulletCommand.Execute(bullet);
}
}
@@ -68,7 +68,8 @@
</Border>
<!-- Add-task row -->
<Border DockPanel.Dock="Top" Classes="add-task" Margin="16,14,16,8">
<Border DockPanel.Dock="Top" Classes="add-task" Margin="16,14,16,8"
IsVisible="{Binding !IsNotesList}">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="add-task-plus" VerticalAlignment="Center">
<PathIcon Width="12" Height="12" Data="{StaticResource Icon.Plus}"
@@ -90,14 +91,6 @@
</Grid>
</Border>
<!-- Notes pinned row (My Day only) -->
<Button DockPanel.Dock="Top"
Classes="btn" HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Margin="16,0,16,8"
IsVisible="{Binding ShowNotesRow}"
Command="{Binding OpenNotesCommand}"
Content="{loc:Tr tasks.notesPinnedRow}"/>
<!-- Task list: one flat, virtualized ListBox. Rows is HeaderRow | TaskRowViewModel — group
headers are regular entries, not a separate ItemsControl per section, so a
VirtualizingStackPanel actually bounds the realized container count.
@@ -106,6 +99,7 @@
unreachable. -->
<Panel>
<ListBox x:Name="RowsListBox"
IsVisible="{Binding !IsNotesList}"
ItemsSource="{Binding Rows}"
Background="Transparent"
BorderThickness="0">
@@ -172,6 +166,11 @@
Foreground="{DynamicResource TextMuteBrush}"
TextAlignment="Center" TextWrapping="Wrap"/>
</StackPanel>
<!-- The notes list is not a task list: same island slot, different content. -->
<Panel IsVisible="{Binding IsNotesList}">
<islands:NotesEditorView DataContext="{Binding Notes}"/>
</Panel>
</Panel>
</DockPanel>
@@ -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;