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:
co-authored by
Claude Opus 5
parent
ae0c7d3771
commit
0f04b796f4
@@ -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);
|
||||
|
||||
@@ -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="‹" Command="{Binding PrevDayCommand}"/>
|
||||
<ctl:ThemedDatePicker SelectedDate="{Binding CurrentDate, Mode=TwoWay}"/>
|
||||
<Button Classes="btn" Content="›" 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>
|
||||
|
||||
Reference in New Issue
Block a user